From da1759322caf4d18919b889cabf28c71321e3ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 13:05:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=20=E5=9B=9E=E5=90=88=E5=BC=80=E5=A7=8B=E5=89=8D=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E6=8C=81=E4=B9=85=E5=8C=96=E8=A7=84=E8=8C=83=E5=8C=96?= =?UTF-8?q?=20user=20prompt=20=E9=99=84=E4=BB=B6=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=B1=20Rust=20=E5=86=99=E5=85=A5?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=8C=96=E5=8E=86=E5=8F=B2=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E5=8E=9F=E5=A7=8B=E6=B6=88=E6=81=AF=E5=86=B2=E7=AA=81?= =?UTF-8?q?=20=E8=A1=A5=E5=85=85=E9=87=8D=E5=90=AF=E5=90=8E=E5=A4=9A?= =?UTF-8?q?=E8=BD=AE=20user/assistant=20=E5=8E=86=E5=8F=B2=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=B5=8B=E8=AF=95=20=E5=90=8C=E6=AD=A5=20DirectProjec?= =?UTF-8?q?t=20replay=20=E6=96=87=E6=A1=A3=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent/direct_runtime.rs | 125 ++++++++++++++++++ apps/ai-game-creator-shell/src/App.tsx | 32 ++--- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 3 files changed, 143 insertions(+), 16 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 08767fced..fc36389d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4192,6 +4192,27 @@ fn persist_direct_codex_assistant_reply_at( .map(|_| ()) } +fn persist_direct_codex_user_prompt_at( + root: &Path, + client_turn_id: &str, + prompt: &str, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "user".to_string(), + content: prompt.trim().to_string(), + agent_id: None, + }, + &format!("direct-codex:{client_turn_id}:user"), + ) + .map(|_| ()) +} + #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, @@ -4223,6 +4244,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; + if let Err(error) = persist_direct_codex_user_prompt_at(root, &turn_id, &user_prompt) { + audit.finish(false); + turn_emitter.emit("failed", Some("none"), None); + return Err(redact_agent_runtime_error( + root, + &format!("Direct 用户消息持久化失败,已拒绝发起回合:{error}"), + 500, + )); + } let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, &user_prompt, @@ -4264,6 +4294,24 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( mod tests { use super::*; + fn direct_test_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + api_key: "fixture-secret".to_string(), + base_url: "https://example.invalid/v1".to_string(), + model: "fixture-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "high".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 64_000, + tool_output_token_limit: 12_000, + request_timeout_ms: 10_000, + max_retries: 0, + retry_backoff_ms: 100, + } + } + #[test] fn client_turn_id_is_strictly_normalized_and_bounded() { assert_eq!( @@ -4329,6 +4377,83 @@ mod tests { ); } + #[test] + fn direct_user_prompt_is_persisted_idempotently_before_reply_and_survives_retry() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-user-project", "用户消息落盘") + .expect("init project"); + let turn_id = "client-turn-user-0001"; + let normalized_prompt = "请先检查附件,再继续上一轮的游戏设计。\n\n项目附件:\n- 原文件名:需求.md;项目路径:需求.md;类型:text/markdown;大小:12 字节"; + + persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) + .expect("persist user prompt before turn"); + persist_direct_codex_user_prompt_at(root.path(), turn_id, normalized_prompt) + .expect("retry reuses the same user message identity"); + + let conversation = read_local_conversation_for_session_at(root.path(), None, None) + .expect("read project conversation"); + let message_id = format!("direct-codex:{turn_id}:user"); + let persisted = conversation + .messages + .iter() + .filter(|message| message.message_id.as_deref() == Some(message_id.as_str())) + .collect::>(); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].role, "user"); + assert_eq!(persisted[0].content, normalized_prompt); + assert!( + persist_direct_codex_user_prompt_at(root.path(), turn_id, "不同的重试请求") + .expect_err("same turn identity cannot be rebound") + .contains("messageId 冲突") + ); + } + + #[test] + fn direct_history_recovers_multiple_user_assistant_turns_after_thread_restart() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-restart-project", "重启恢复") + .expect("init project"); + let request = LlmRunRequest::single_turn("system", "继续实现第二轮需求") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + + persist_direct_codex_user_prompt_at(root.path(), "client-turn-0001", "先做一个主菜单") + .expect("persist first user turn"); + persist_direct_codex_assistant_reply_at( + root.path(), + "client-turn-0001", + "主菜单已完成,下一步可以继续扩展关卡。", + ) + .expect("persist first assistant turn"); + persist_direct_codex_user_prompt_at( + root.path(), + "client-turn-0002", + "请在主菜单基础上增加关卡选择", + ) + .expect("persist second user turn"); + persist_direct_codex_assistant_reply_at( + root.path(), + "client-turn-0002", + "关卡选择页面已加入,并保留主菜单入口。", + ) + .expect("persist second assistant turn"); + + // 模拟 app-server 重启后没有内存 thread:从项目 JSONL 重建时, + // 两轮完整的 user/assistant 上下文都必须保留。 + let prompt = crate::agent::codex_app_server::build_direct_codex_history_prompt( + root.path(), + "client-turn-0003", + "继续实现第二轮需求", + &request, + &direct_test_llm(), + ) + .expect("rebuild history after restart"); + assert_eq!( + prompt, + "user: 先做一个主菜单\nassistant: 主菜单已完成,下一步可以继续扩展关卡。\nuser: 请在主菜单基础上增加关卡选择\nassistant: 关卡选择页面已加入,并保留主菜单入口。\nuser: 继续实现第二轮需求" + ); + } + #[test] fn direct_turn_update_payload_is_the_exact_camel_case_contract() { let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 62b6ac4a0..e0507c47b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -5447,23 +5447,25 @@ export function App({ ); directCodexInterruptedPartialRef.current = null; } - // Persist the original user intent and stable turn identity before - // Codex can start any billable or externally visible work. The - // regular conversation writer may race this call, but messageId - // idempotency makes both writers converge on the same record. - await directInvoke( - 'append_local_conversation_message', - { - projectPath: directProjectPath, - agentId: null, - messageId: directUserMessageId, - message: { - role: 'user', - content: prompt, + // Rust owns the normalized user record for attachment turns so the + // durable message includes the same bounded project mapping that is + // sent to Codex. Plain turns keep the optimistic browser write; the + // Rust writer then converges on it through messageId idempotency. + if (!attachments?.length) { + await directInvoke( + 'append_local_conversation_message', + { + projectPath: directProjectPath, agentId: null, + messageId: directUserMessageId, + message: { + role: 'user', + content: prompt, + agentId: null, + }, }, - }, - ); + ); + } const directTurnInput: { projectPath: string; prompt: string; diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 031b32256..26ff7a230 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -219,7 +219,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 模式升级:`agentMode` 扩为 `codex_app_server / codex_cli / provider`,新默认为 `codex_app_server`;V1.51 的一次性 `codex exec` 保留为显式兼容模式,HTTP Provider 保留为非 Responses 配置及故障回退模式。 - 进程与节点:External Runner 按“有效 Agent LLM 凭据/Responses 路由 + `projectId/agentId/sessionId/runId`”隔离长期 `codex app-server --stdio`,即每个权威节点 run 直接持有自己的 Codex CLI 子进程与 ephemeral thread,每次完整权威请求映射 turn。同一节点 turn 串行,节点之间进程级隔离;单节点连接失败不得使其它节点同时失去终态。Codex thread 不写 durable recovery;节点完成、重启、retry、handoff 和 finalization 仍只认 AGC 账本。 -- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一、append-only 事实源;每个 GUI turn 在发起 app-server turn 前,先把渲染后的规范化 user prompt 以 `direct-codex:{clientTurnId}:user` 幂等追加,持久化失败则不发起 turn 并公开 failed;LLM 失败 / 中断时保留该 user 记录,重试同一 `clientTurnId` 只复用它。仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取历史,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。为避免持久增长的历史超过模型上下文,replay builder 使用全局 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 和 4096 安全余量计算输入预算,从最新记录向前选择连续、完整的消息;超预算的旧前缀只在本次 prompt 中省略,不改写 JSONL、不写 summary/sidecar、不拆分单条记录。发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 行;当前 user request 始终保留,若其自身超过硬上下文预算则直接失败。这里的简单 role 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)仍是产品合同:保持 prompt 形状稳定、避免 envelope breaking change,并让模型明确知道上次输出在断开处结束。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 - LLM 配置:`apiKind` 始终只接受 `openai_responses`;非空 Key 转换为 app-server model provider,base URL 生效,Key 仅走专用环境变量;空 Key 只桥接用户 Codex `auth.json`,不继承环境 `CODEX_API_KEY`。设置面板在 app-server 模式继续显示并保存 model、effort、stream、全局/逐 Agent Key 与路由配置;`openai_chat / anthropic` 明确提示切 `provider`,不得悄悄忽略。`stream=true` 接入 app-server 文本 delta;`webSearchEnabled=true` 只允许 DirectProject 经客户端审核的 `agc_web_search` 使用,不得启用 Codex 原生 webSearch 或任意网络。 - 安全与取消:临时 cwd、隔离 `CODEX_HOME` 与 OS HOME、read-only、network off、never approval,并在启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力;取消从 turn-start pending 阶段就跟踪且只 interrupt 当前 turn。已发送 turn 后连接断开或终态丢失进入 reconciliation,只关闭当前节点进程且不重放同一 request slot;明确 failed/interrupted 不按 transport 重试。 - remote-control 认证边界:没有 ChatGPT `auth.json` 的 API Key / provider-proxy app-server 在启动时设置 Codex 内部环境变量 `CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED=1`,让 remote-control 以 `desired_state=Disabled` 启动,避免上游进入 1Hz 认证重试;不再依赖需要 ChatGPT 登录态的 `remoteControl/disable` RPC。只有实际桥接 ChatGPT 登录态的 AuthBridge 保持 remote-control 可用。API Key 子进程同时使用 `RUST_LOG=warn` 收敛剩余预期噪音,不伪造 `auth.json` 或静默继续。