实现 DirectProject 用户消息预写与回显过滤
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

在 turn/start 前幂等写入 AGC 规范化 user item

过滤 Codex userMessage 与 raw role=user 回显,保留 assistant/tool/partial 落盘

补充用户消息来源隔离回归测试
This commit is contained in:
2026-09-07 15:41:04 +08:00
parent ef64d2fa52
commit 928fc6ffbc
3 changed files with 99 additions and 8 deletions
@@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock, Weak};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
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_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
@@ -2129,15 +2130,12 @@ impl CodexAppServerConnection {
llm: &GameCreatorLlmConfig,
request: LlmRunRequest,
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 direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
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 history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
@@ -2147,6 +2145,21 @@ impl CodexAppServerConnection {
"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 {
let items = match read_direct_project_history_items_at(history_root) {
Ok(items) => items,
@@ -2203,7 +2216,7 @@ impl CodexAppServerConnection {
model,
&self.inner.workspace_path,
self.inner.workspace_mode,
_direct_client_turn_id,
direct_client_turn_id,
);
apply_game_creator_codex_app_server_reasoning_effort(&mut params, &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_request_timeout_ms(config.llm.request_timeout_ms)
.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
.run_turn_with_direct_observer_and_history(
&snapshot,
&config.llm,
request,
Some(&codex_root),
client_turn_id,
effective_client_turn_id,
None,
observer,
audit,
@@ -178,11 +178,32 @@ fn direct_project_history_item_from_line(
pub(crate) fn append_direct_project_history_item_at(
root: &Path,
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> {
enforce_project_permission_policy(root, "conversation.write")?;
if is_direct_project_internal_context_item(item) {
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 path = history_path(root);
let history_exists =
@@ -203,6 +224,19 @@ pub(crate) fn append_direct_project_history_item_at(
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(
role: &str,
content: &str,
@@ -321,7 +355,7 @@ pub(crate) fn read_direct_project_chat_history_at(
#[cfg(test)]
mod tests {
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,
};
use serde_json::json;
@@ -394,4 +428,40 @@ mod tests {
read_direct_project_history_items_at(root.path()).expect("read repaired history");
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,
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)]