diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index f3837c648..b46336b66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -3,7 +3,7 @@ use ts_rs::TS; /// DirectProject 本轮 user input 的唯一结构化入口。 #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserItem { #[serde(rename = "message")] @@ -11,7 +11,7 @@ pub(crate) enum DirectCodexUserItem { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageItem { pub(crate) role: DirectCodexUserRole, @@ -27,7 +27,7 @@ pub(crate) enum DirectCodexUserRole { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "input_text")] @@ -39,7 +39,7 @@ pub(crate) enum DirectCodexUserContentPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) label: String, @@ -62,8 +62,64 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart { } #[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) struct DirectCodexUserMessageEnvelope { pub(crate) item: DirectCodexUserItem, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn resource_reference_serializes_with_only_camel_case_resource_id() { + let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + role: DirectCodexUserRole::User, + content: vec![DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }], + id: "turn-1".to_string(), + }); + assert_eq!( + serde_json::to_value(item).expect("serialize user item"), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero" + }], + "id": "turn-1" + }) + ); + } + + #[test] + fn resource_reference_rejects_extra_identity_fields() { + let error = serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_resource_reference", + "resourceId": "asset-hero", + "label": "主角" + }], + "id": "turn-1" + })) + .expect_err("label must not be accepted on resource reference"); + assert!(error.to_string().contains("unknown field"), "{error}"); + } + + #[test] + fn unknown_content_part_fails_closed() { + serde_json::from_value::(json!({ + "type": "message", + "role": "user", + "content": [{"type": "future_part", "value": "x"}], + "id": "turn-1" + })) + .expect_err("unknown content part must fail closed"); + } +} 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 1a07d2c95..99d4262c4 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 @@ -110,3 +110,34 @@ pub(crate) fn direct_codex_user_item_to_prompt( } }) } + +#[cfg(test)] +mod tests { + use super::direct_codex_user_item_to_response_item; + use serde_json::json; + use std::path::Path; + + #[test] + fn standard_response_item_passes_through_without_agc_private_parts() { + let item = json!({ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "完成"}] + }); + assert_eq!( + direct_codex_user_item_to_response_item(Path::new("/unused"), &item) + .expect("assistant response item should pass through"), + item + ); + } + + #[test] + fn history_item_without_type_fails_closed() { + let error = direct_codex_user_item_to_response_item( + Path::new("/unused"), + &json!({"role": "assistant"}), + ) + .expect_err("history item without type must fail"); + assert!(error.contains("缺少 type"), "{error}"); + } +} diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index bf46784af..17bd7c6a3 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -16,16 +16,16 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 {"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"你好"}]}} ``` -`payload` 必须是未经改写的 Responses item。Direct 回合不由浏览器预写用户 message;Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。显式的本地 user/assistant 补写只能通过受权限保护的 `append_direct_project_conversation_message` 命令完成。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 +`payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存 delta/started 事件。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 -读侧白名单兼容旧 `{role,content}` 行:格式切换前,DirectProject 主对话由通用对话写入器落到同一份 `project.jsonl`,存量用户项目的历史文件整份都是这种行。读取时把**明确枚举的那一种**旧行形状(`schemaVersion=game-creator-conversation.v1`、无 `type`、role 在 legacy 写入器自己的角色集合 `user`/`assistant`/`tool` 内、content 为非空字符串)认下来:`user`/`assistant` 投影成与 `direct_project_local_message_item` 同形状的 message item,`role` 与 `content` 逐字节保留;`tool` 行已识别但不进 Codex 上下文(它不是 Responses item,无法还原成真正的工具 item,聊天投影本来也只展示 user/assistant),与 developer/system item 同样过滤。角色集合取的是 `project/conversation.rs` 里那条 `matches!(role, "user" | "assistant" | "tool")` 校验,所以「legacy 写入器能写出的行」被完整覆盖;白名单之外的角色、换了 `schemaVersion`、带 `type`、content 非字符串或为空、缺 `payload`、坏 JSON 仍按损坏失败关闭。这条兼容是只读的,不迁移、不改写历史文件。 +读取只接受 `response_item` envelope;旧 `game-creator-conversation.v1` 行不提供迁移或 fallback,直接失败关闭。未知或无法投影的 canonical item 在发送前失败,不能产生本轮新增历史。 ## 正常回合 1. 启动 `ephemeral: true` 线程,并启用 `experimentalRawEvents: true`。 -2. 新线程先把历史 item 数组一次注入;注入成功后执行新的 `turn/start`。本轮用户 item 只接受 Codex 回传的 `rawResponseItem/completed`,不由 AGC 预写。 +2. 新线程先把历史 item 数组逐项投影为 Codex 可接受 item 后一次注入;注入成功后执行新的 `turn/start`。本轮 canonical user item 在发送前完成同样的投影校验,再写入项目历史。 3. 收到 `rawResponseItem/completed` 后立即追加其 `params.item` 并 flush。 4. 正常 `turn/completed: completed` 不生成额外记录。 @@ -55,12 +55,10 @@ Codex 启动时注入的 `host_skills.instructions`、`permissions.instructions` DirectProject 的浏览器层只负责显示和乐观状态,不再调用通用对话写入器。历史读写与回合累计分别位于 `agent/direct_project_history.rs` 和 `agent/direct_project_turn_history.rs`。 -`project.jsonl` 不是 DirectProject 独享的写入方:通用对话链(`project/conversation.rs`)把「项目主对话」(`agent_id=None`)映射到同一份文件,非 DirectProject 模式(`codex_cli`/`provider`)的项目对话、以及 Agent Runtime 的项目级公开状态消息(`agent/runtime_state.rs` 的 public status 写入点)都由它追加 `game-creator-conversation.v1` 行。两条链的行形状不同但**互读兼容**:DirectProject 侧投影旧行(见上),通用对话侧跳过 `type=response_item` 且带 `payload` 的行(不把它二次投影成自己的记录,DirectProject 侧已经拥有那份投影),其余坏行两侧都失败关闭。因此同一份文件里出现两种行不会让任何一侧失败。 - -这里**故意不给通用对话写入器加「文件已属于 DirectProject 就拒绝追加」的硬报错**:这些写入点不是尽力而为的旁路——`agent/runtime_driver/task_start.rs` 在 `ensure_game_creator_agent_runtime_accepted_public_status_at` 返回 `Err` 时会直接中止本次后台任务(「后台任务启动确认落盘失败,任务未执行」),`agent/runtime_protocol/steering.rs` 的三处调用也用 `?` 上抛。加硬报错会把「旧行噪声」换成「任务起不来」,比它要解决的问题更糟;而毒化本身已经不可能发生——legacy 写入器的行形状与角色集合都被 `conversation.rs` 的校验穷举,全部落在 DirectProject 的读侧白名单内。 +`project.jsonl` 的 DirectProject 现行合同只允许 `response_item` envelope。其它模式产生的旧 conversation 行不属于本合同,不得注入 DirectProject。 ## 写入与损坏边界 -写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;白名单化的旧行投影成 message item;其余中间坏行直接失败。兼容只发生在读取侧,不对旧格式做数据迁移或改写。 +写入使用 `write_all + flush`。读取时允许丢弃文件末尾一条不完整 JSON 行;非 `response_item` 行和无法投影的 item 直接失败,不做数据迁移或 fallback。 该失败有专门恢复提示,并按不可重试处理:同一份历史文件每次读都会得到同一结论,重试不会改变结果,因此不会向用户显示「可直接重试」。