From d48d09905dd604ceb4c6de4565b45f74f47d3d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:42:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E4=BD=BF=E7=94=A8=20DirectPr?= =?UTF-8?q?oject=20Response=20item=20=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 game-creator-conversation.v1 读取 fallback 历史注入前逐项投影 canonical user item 为 Codex message 无效 item 在持久化前失败关闭 --- .../direct_project_history_wire.rs | 9 +- .../src/agent/codex_app_server/mod.rs | 6 +- .../src/agent/direct_codex_user_item/mod.rs | 5 +- .../src/agent/direct_codex_user_item/wire.rs | 30 +++ .../src/agent/direct_project_history.rs | 240 ++---------------- 5 files changed, 66 insertions(+), 224 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs index b958bd982..627002154 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -11,8 +11,15 @@ pub(super) fn build_direct_project_history_injection_params( history_root: &Path, thread_id: &str, ) -> Result { - let items = read_direct_project_history_items_at(history_root) + let canonical_items = read_direct_project_history_items_at(history_root) .map_err(platform_llm::LlmError::InvalidRequest)?; + let items = canonical_items + .iter() + .map(|item| { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest) + }) + .collect::, _>>()?; let params = serde_json::json!({"threadId": thread_id, "items": items}); let payload_bytes = serde_json::to_vec(¶ms) .map(|bytes| bytes.len().saturating_add(1)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index d6174b5d8..a86ef26b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -2702,7 +2702,11 @@ impl CodexAppServerConnection { } if let Some(client_turn_id) = direct_client_turn_id { let user_item = match direct_user_item { - Some(item) => item.clone(), + Some(item) => { + direct_codex_user_item_to_response_item(history_root, item) + .map_err(platform_llm::LlmError::InvalidRequest)?; + item.clone() + } None => direct_project_local_message_item( "user", current_prompt, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index d591f4dad..d5ccc94c4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -9,4 +9,7 @@ pub(crate) use model::{ DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; -pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; +pub(crate) use wire::{ + direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, + direct_codex_user_item_to_wire_input, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c2ab37897..1a07d2c95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -4,6 +4,36 @@ use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; use serde_json::Value; use std::path::Path; +/// 将历史中的 canonical user item 投影为 Codex `response_item` message。 +/// 非 user message 的标准 Response item 原样返回;未知形状直接失败。 +pub(crate) fn direct_codex_user_item_to_response_item( + root: &Path, + item: &Value, +) -> Result { + let is_user_message = item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user"); + if !is_user_message { + if item.get("type").and_then(Value::as_str).is_some() { + return Ok(item.clone()); + } + return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string()); + } + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?; + let Value::Array(content) = direct_codex_user_item_to_wire_input(root, &canonical)? else { + return Err("DirectProject user item wire content 不是数组".to_string()); + }; + let mut projected = serde_json::json!({ + "type": "message", + "role": "user", + "content": content, + }); + if let Some(id) = item.get("id").and_then(Value::as_str) { + projected["id"] = Value::String(id.to_string()); + } + Ok(projected) +} + /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index df80cc7ca..086a9b1f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -17,9 +17,6 @@ const DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED: usize = 1; /// 写入侧与读取侧共用同一个信封类型:`project.jsonl` 由项目主对话与 DirectProject 共享, /// 这个值一旦只在写入侧改动,读取侧就会把对方的行当成坏行,整份历史立刻读不出来。 pub(crate) const DIRECT_PROJECT_HISTORY_RECORD_TYPE: &str = "response_item"; -/// 格式切换到 `response_item`(#282)之前,DirectProject 主对话通过通用对话写入器 -/// 落到同一份 `project.jsonl`,行形状是 `PersistedLocalConversationMessageRecord`。 -const DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; const DIRECT_PROJECT_INTERNAL_CONTEXT_KINDS: &[&str] = &[ "host_skills.instructions", "permissions.instructions", @@ -180,79 +177,22 @@ fn direct_project_history_item_from_line( Ok(Some(item)) } -/// 只接受两种行信封:`response_item`,以及白名单化的 legacy 行。返回 `None` 表示行已 -/// 被识别、但不该进入 Codex 上下文(legacy 的 `tool` 行)。 -/// 其余任何形状(含换了 `schemaVersion`、带 `type` 却不是 `response_item`、role 不在 -/// legacy 写入器自己的角色集合内、content 不是非空字符串)都判定为损坏并失败关闭。 +/// 只接受 `response_item` 行信封;其它历史格式不提供迁移或 fallback,直接失败关闭。 fn direct_project_history_item_from_parsed_line( path: &Path, parsed: &Value, ) -> Result, String> { - if parsed.get("type").and_then(Value::as_str) == Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { - return parsed - .get("payload") - .cloned() - .map(Some) - .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())); + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_PROJECT_HISTORY_RECORD_TYPE) { + return Err(format!( + "DirectProject 历史记录类型无效:{}", + path.display() + )); } - direct_project_legacy_row(parsed) - .map(|row| row.message_item()) - .ok_or_else(|| format!("DirectProject 历史记录类型无效:{}", path.display())) -} - -/// 白名单化 legacy 行的投影结果。 -enum DirectProjectLegacyRow { - /// user/assistant 行:投影成 message item 进入历史。 - Message(Value), - /// 已识别但不可注入的行(`tool`):与 developer/system item 同样处理,不进 Codex - /// 上下文。legacy 行不是 Responses item,`tool` 行无法还原成真正的工具 item, - /// 注入会造出假的工具消息;聊天投影本来也只展示 user/assistant。 - NotChat, -} - -impl DirectProjectLegacyRow { - fn message_item(self) -> Option { - match self { - DirectProjectLegacyRow::Message(item) => Some(item), - DirectProjectLegacyRow::NotChat => None, - } - } -} - -/// 格式切换前的 legacy 行投影。存量用户项目的历史文件全是这种行,读取时投影成与 -/// `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` -/// 原样保留(不 trim、不改写、不合并),未知字段忽略。 -/// -/// 角色白名单取的是 legacy 写入器自己的角色集合,也就是 -/// `project/conversation.rs` 里 `matches!(role, "user" | "assistant" | "tool")` 这一 -/// 条校验,所以「legacy 写入器能写出的行」被完整覆盖,不会有第三种角色漏进来; -/// 白名单之外的角色(手改文件、未来写入器)仍按损坏失败关闭。 -fn direct_project_legacy_row(parsed: &Value) -> Option { - let object = parsed.as_object()?; - if object.contains_key("type") - || object.get("schemaVersion").and_then(Value::as_str) - != Some(DIRECT_PROJECT_HISTORY_LEGACY_SCHEMA_VERSION) - { - return None; - } - let role = object.get("role").and_then(Value::as_str)?; - if !matches!(role, "user" | "assistant" | "tool") { - return None; - } - let content = object.get("content").and_then(Value::as_str)?; - if content.is_empty() { - return None; - } - if role == "tool" { - return Some(DirectProjectLegacyRow::NotChat); - } - let message_id = object - .get("messageId") - .and_then(Value::as_str) - .filter(|message_id| !message_id.is_empty()); - Some(DirectProjectLegacyRow::Message( - direct_project_message_item(role, content, message_id), - )) + parsed + .get("payload") + .cloned() + .map(Some) + .ok_or_else(|| format!("DirectProject 历史记录缺少 payload:{}", path.display())) } /// 测试专用探针:在"锁外幂等回扫"这一段的两端回调。 @@ -637,9 +577,8 @@ mod tests { std::fs::write(&path, format!("{}\n", lines.join("\n"))).expect("write history fixture"); } - const LEGACY_USER_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"user","content":"请创建菜单","agentId":null,"messageId":"direct-codex:turn-0001:user","updatedAt":1757000000}"#; - const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#; const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; + const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// @@ -648,7 +587,7 @@ mod tests { #[test] fn contention_failure_is_retried_with_bounded_backoff() { let root = init_history_project("contention-retry"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -680,7 +619,7 @@ mod tests { #[test] fn contention_failure_beyond_the_backoff_budget_fails_closed() { let root = init_history_project("contention-bounded"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let marker = root .path() .join(".agent/runtime/test-fail-next-direct-project-history-append"); @@ -723,7 +662,7 @@ mod tests { write_history_lines( root.path(), &[ - LEGACY_USER_ROW, + RESPONSE_ITEM_ROW, r#"{"schemaVersion":"game-creator"#, RESPONSE_ITEM_ROW, ], @@ -761,7 +700,7 @@ mod tests { let root = init_history_project("scan-outside-lock"); write_history_lines( root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], + &[RESPONSE_ITEM_ROW, RESPONSE_ITEM_ROW, RESPONSE_ASSISTANT_ROW], ); let path = history_path(root.path()); let fired = Arc::new(AtomicBool::new(false)); @@ -805,7 +744,7 @@ mod tests { #[test] fn concurrent_append_after_the_out_of_lock_scan_still_prevents_a_duplicate_row() { let root = init_history_project("scan-stale-state"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let path = history_path(root.path()); let item = json!({ "type": "message", @@ -845,7 +784,7 @@ mod tests { #[test] fn conflicting_item_id_still_fails_closed_with_the_scan_outside_the_lock() { let root = init_history_project("id-conflict-outside-lock"); - write_history_lines(root.path(), &[LEGACY_USER_ROW]); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); append_direct_project_history_item_at( root.path(), &json!({ @@ -1033,91 +972,7 @@ mod tests { } #[test] - fn legacy_conversation_rows_project_into_responses_message_items() { - let root = init_history_project("legacy-projection"); - write_history_lines(root.path(), &[LEGACY_USER_ROW, LEGACY_ASSISTANT_ROW]); - - let items = read_direct_project_history_items_at(root.path()).expect("read legacy history"); - assert_eq!( - items, - vec![ - json!({ - "type": "message", - "role": "user", - "id": "direct-codex:turn-0001:user", - "content": [{"type": "input_text", "text": "请创建菜单"}], - }), - json!({ - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "已完成 第一行\n第二行 "}], - }), - ] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read legacy chat"); - assert_eq!(chat.messages.len(), 2); - assert_eq!(chat.messages[0].role, "user"); - assert_eq!(chat.messages[0].content, "请创建菜单"); - assert_eq!( - chat.messages[0].message_id.as_deref(), - Some("direct-codex:turn-0001:user") - ); - assert_eq!(chat.messages[1].role, "assistant"); - assert_eq!(chat.messages[1].content, "已完成 第一行\n第二行 "); - assert_eq!(chat.messages[1].message_id, None); - } - - #[test] - fn mixed_legacy_and_response_item_history_reads_in_file_order() { - let root = init_history_project("mixed-history"); - write_history_lines( - root.path(), - &[LEGACY_USER_ROW, RESPONSE_ITEM_ROW, LEGACY_ASSISTANT_ROW], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read mixed history"); - assert_eq!( - items - .iter() - .map(|item| item.get("id").and_then(Value::as_str)) - .collect::>(), - vec![ - Some("direct-codex:turn-0001:user"), - Some("codex-item-2"), - None - ] - ); - assert_eq!( - items[1], - json!({ - "type": "message", - "role": "user", - "id": "codex-item-2", - "content": [{"type": "input_text", "text": "再加一个按钮"}], - }) - ); - assert_eq!( - items[2]["content"][0]["text"], - json!("已完成 第一行\n第二行 ") - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read mixed chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| (message.role.as_str(), message.content.as_str())) - .collect::>(), - vec![ - ("user", "请创建菜单"), - ("user", "再加一个按钮"), - ("assistant", "已完成 第一行\n第二行 "), - ] - ); - } - - #[test] - fn legacy_compat_keeps_unrecognized_rows_failing_closed() { + fn non_response_item_history_fails_closed() { let unrecognized_rows = [ // 带 type 却不是 response_item r#"{"type":"message","role":"user","content":[{"type":"input_text","text":"x"}]}"#, @@ -1159,61 +1014,4 @@ mod tests { .expect_err("broken json must fail closed"); assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); } - - #[test] - fn legacy_tool_row_is_recognized_but_stays_out_of_the_codex_context() { - let root = init_history_project("legacy-tool-row"); - write_history_lines( - root.path(), - &[ - LEGACY_USER_ROW, - r#"{"schemaVersion":"game-creator-conversation.v1","role":"tool","content":"{\"ok\":true}","agentId":null,"updatedAt":1757000002}"#, - LEGACY_ASSISTANT_ROW, - ], - ); - - let items = read_direct_project_history_items_at(root.path()).expect("read history"); - assert_eq!( - items - .iter() - .map(|item| item["role"].as_str().unwrap_or_default()) - .collect::>(), - vec!["user", "assistant"] - ); - - let chat = read_direct_project_chat_history_at(root.path()).expect("read chat"); - assert_eq!( - chat.messages - .iter() - .map(|message| message.role.as_str()) - .collect::>(), - vec!["user", "assistant"] - ); - } - - #[test] - fn generic_writer_rows_are_covered_by_the_legacy_whitelist() { - let root = init_history_project("generic-writer-shape"); - crate::append_local_conversation_message_at( - root.path(), - None, - crate::LocalConversationMessage { - role: "user".to_string(), - content: "通用写入器写的旧格式回合".to_string(), - agent_id: None, - }, - ) - .expect("append legacy row through the generic writer"); - - let items = - read_direct_project_history_items_at(root.path()).expect("read generic writer row"); - assert_eq!( - items, - vec![json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "通用写入器写的旧格式回合"}], - })] - ); - } }