实现 DirectProject 用户消息预写与回显过滤
在 turn/start 前幂等写入 AGC 规范化 user item 过滤 Codex userMessage 与 raw role=user 回显,保留 assistant/tool/partial 落盘 补充用户消息来源隔离回归测试
This commit is contained in:
@@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|||||||
use std::sync::{Arc, OnceLock, Weak};
|
use std::sync::{Arc, OnceLock, Weak};
|
||||||
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
|
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
|
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
|
||||||
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
|
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
|
||||||
@@ -2129,15 +2130,12 @@ impl CodexAppServerConnection {
|
|||||||
llm: &GameCreatorLlmConfig,
|
llm: &GameCreatorLlmConfig,
|
||||||
request: LlmRunRequest,
|
request: LlmRunRequest,
|
||||||
direct_history_root: Option<&std::path::Path>,
|
direct_history_root: Option<&std::path::Path>,
|
||||||
_direct_client_turn_id: Option<&str>,
|
direct_client_turn_id: Option<&str>,
|
||||||
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||||
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||||
mut audit: Option<&mut DirectCodexTurnAudit>,
|
mut audit: Option<&mut DirectCodexTurnAudit>,
|
||||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||||
let _turn_guard = self.inner.turn_gate.lock().await;
|
let _turn_guard = self.inner.turn_gate.lock().await;
|
||||||
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
|
|
||||||
self.wait_for_initial_client_mcp_startup().await;
|
|
||||||
let thread_id = thread_lease.thread_id.clone();
|
|
||||||
let mut request = request;
|
let mut request = request;
|
||||||
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
|
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
|
||||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
@@ -2147,6 +2145,21 @@ impl CodexAppServerConnection {
|
|||||||
"DirectProject 用户消息不能为空".to_string(),
|
"DirectProject 用户消息不能为空".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Some(client_turn_id) = direct_client_turn_id {
|
||||||
|
let user_item = direct_project_local_message_item(
|
||||||
|
"user",
|
||||||
|
current_prompt,
|
||||||
|
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||||
|
)
|
||||||
|
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||||
|
append_direct_project_user_message_at(history_root, &user_item)
|
||||||
|
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?;
|
||||||
|
self.wait_for_initial_client_mcp_startup().await;
|
||||||
|
let thread_id = thread_lease.thread_id.clone();
|
||||||
|
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
if thread_created {
|
if thread_created {
|
||||||
let items = match read_direct_project_history_items_at(history_root) {
|
let items = match read_direct_project_history_items_at(history_root) {
|
||||||
Ok(items) => items,
|
Ok(items) => items,
|
||||||
@@ -2203,7 +2216,7 @@ impl CodexAppServerConnection {
|
|||||||
model,
|
model,
|
||||||
&self.inner.workspace_path,
|
&self.inner.workspace_path,
|
||||||
self.inner.workspace_mode,
|
self.inner.workspace_mode,
|
||||||
_direct_client_turn_id,
|
direct_client_turn_id,
|
||||||
);
|
);
|
||||||
apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request);
|
apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request);
|
||||||
if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) {
|
if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) {
|
||||||
@@ -3288,13 +3301,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
|||||||
.with_model(config.llm.model.clone())
|
.with_model(config.llm.model.clone())
|
||||||
.with_request_timeout_ms(config.llm.request_timeout_ms)
|
.with_request_timeout_ms(config.llm.request_timeout_ms)
|
||||||
.with_max_output_tokens(16_000);
|
.with_max_output_tokens(16_000);
|
||||||
|
let generated_client_turn_id;
|
||||||
|
let effective_client_turn_id = match client_turn_id {
|
||||||
|
Some(client_turn_id) => Some(client_turn_id),
|
||||||
|
None => {
|
||||||
|
generated_client_turn_id = format!("direct-cli-{}", Uuid::new_v4());
|
||||||
|
Some(generated_client_turn_id.as_str())
|
||||||
|
}
|
||||||
|
};
|
||||||
connection
|
connection
|
||||||
.run_turn_with_direct_observer_and_history(
|
.run_turn_with_direct_observer_and_history(
|
||||||
&snapshot,
|
&snapshot,
|
||||||
&config.llm,
|
&config.llm,
|
||||||
request,
|
request,
|
||||||
Some(&codex_root),
|
Some(&codex_root),
|
||||||
client_turn_id,
|
effective_client_turn_id,
|
||||||
None,
|
None,
|
||||||
observer,
|
observer,
|
||||||
audit,
|
audit,
|
||||||
|
|||||||
@@ -178,11 +178,32 @@ fn direct_project_history_item_from_line(
|
|||||||
pub(crate) fn append_direct_project_history_item_at(
|
pub(crate) fn append_direct_project_history_item_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &Value,
|
item: &Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
append_direct_project_history_item_at_with_user_policy(root, item, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a user message authored by AGC itself. Codex response items use
|
||||||
|
/// the default path above, which deliberately ignores echoed user messages;
|
||||||
|
/// this explicit entry point keeps the two sources distinct.
|
||||||
|
pub(crate) fn append_direct_project_user_message_at(
|
||||||
|
root: &Path,
|
||||||
|
item: &Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
append_direct_project_history_item_at_with_user_policy(root, item, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_direct_project_history_item_at_with_user_policy(
|
||||||
|
root: &Path,
|
||||||
|
item: &Value,
|
||||||
|
allow_user_item: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
enforce_project_permission_policy(root, "conversation.write")?;
|
enforce_project_permission_policy(root, "conversation.write")?;
|
||||||
if is_direct_project_internal_context_item(item) {
|
if is_direct_project_internal_context_item(item) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if !allow_user_item && is_direct_project_codex_user_item(item) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
|
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
|
||||||
let path = history_path(root);
|
let path = history_path(root);
|
||||||
let history_exists =
|
let history_exists =
|
||||||
@@ -203,6 +224,19 @@ pub(crate) fn append_direct_project_history_item_at(
|
|||||||
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
|
append_jsonl_line_unlocked(&path, &line, "DirectProject 历史")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_direct_project_codex_user_item(item: &Value) -> bool {
|
||||||
|
if item.get("type").and_then(Value::as_str) == Some("userMessage") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if item.get("role").and_then(Value::as_str) != Some("user") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!item
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|id| id.starts_with("direct-codex:") && id.ends_with(":user"))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn direct_project_local_message_item(
|
pub(crate) fn direct_project_local_message_item(
|
||||||
role: &str,
|
role: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
@@ -321,7 +355,7 @@ pub(crate) fn read_direct_project_chat_history_at(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
append_direct_project_history_item_at, history_path,
|
append_direct_project_history_item_at, append_direct_project_user_message_at, history_path,
|
||||||
is_direct_project_internal_context_item, read_direct_project_history_items_at,
|
is_direct_project_internal_context_item, read_direct_project_history_items_at,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -394,4 +428,40 @@ mod tests {
|
|||||||
read_direct_project_history_items_at(root.path()).expect("read repaired history");
|
read_direct_project_history_items_at(root.path()).expect("read repaired history");
|
||||||
assert_eq!(items, vec![item]);
|
assert_eq!(items, vec![item]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_user_echo_is_filtered_but_agc_user_message_is_persisted() {
|
||||||
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
|
crate::init_local_game_project_at(root.path(), "user-echo", "用户回显过滤")
|
||||||
|
.expect("init project");
|
||||||
|
let user = serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "direct-codex:turn-0001:user",
|
||||||
|
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||||
|
});
|
||||||
|
append_direct_project_user_message_at(root.path(), &user).expect("persist AGC user");
|
||||||
|
append_direct_project_history_item_at(
|
||||||
|
root.path(),
|
||||||
|
&serde_json::json!({
|
||||||
|
"type": "userMessage",
|
||||||
|
"id": "codex-user-item-1",
|
||||||
|
"clientId": "turn-0001",
|
||||||
|
"content": [{"type": "text", "text": "请创建菜单"}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("ignore Codex echo");
|
||||||
|
append_direct_project_history_item_at(
|
||||||
|
root.path(),
|
||||||
|
&serde_json::json!({
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"id": "codex-raw-user-item-1",
|
||||||
|
"content": [{"type": "input_text", "text": "请创建菜单"}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("ignore raw Codex user echo");
|
||||||
|
let items = read_direct_project_history_items_at(root.path()).expect("read history");
|
||||||
|
assert_eq!(items, vec![user]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4241,7 +4241,7 @@ fn persist_direct_codex_user_prompt_at(
|
|||||||
prompt,
|
prompt,
|
||||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||||
)?;
|
)?;
|
||||||
append_direct_project_history_item_at(root, &item)
|
append_direct_project_user_message_at(root, &item)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user