From 609efe805cccc9bdf1fbe0e64da940dcc6928fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 17:43:23 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E8=A1=A5=E5=85=85DirectProject=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=81=A2=E5=A4=8D=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明确ephemeral线程与AGC聊天事实源 记录partial中断标记和取消自动重发 --- docs/project-memory/shared-memory/decision-log.md | 1 + .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7c590d5ae..63f4a5cff 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7568,6 +7568,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 配置:有无 AGC LLM Key 都只支持 `openai_responses`;非空 Key 映射后 base URL 与逐 Agent model/effort 生效,Key 只经专用环境变量;空 Key 只桥接用户 Codex `auth.json`,移除继承环境 Key。`stream=true` 复用现有 durable final-reply delta;`webSearchEnabled=true`、`openai_chat / anthropic` 必须显式切换 `provider`,不得静默忽略已有 LLM 配置。 - 安全与恢复:app-server 使用隔离临时 `CODEX_HOME` 与 OS HOME,只桥接认证,不加载用户 MCP/config/skills/hooks;启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力,固定 read-only、network off、never approval,AGC 是唯一 ToolHost。取消覆盖 turn-start 回包前窗口并只发送单 turn interrupt;已开始 turn 的连接/终态未知直接标记 reconciliation,明确 failed/interrupted 不自动重试。模式与 durable 指纹取同一配置快照。 - 资源与退出:pool 按实际凭据快照/base URL/API kind/CLI 版本和节点 run 身份隔离;空 AppData Key 只允许桥接一次性读取的有界 `auth.json` 快照,并用同一字节快照生成池指纹,宿主 `CODEX_API_KEY` 对 app-server 与一次性 CLI 都必须移除。节点进程与 thread 均有上限并只淘汰 inactive LRU;stdout NDJSON 与 stderr 无换行记录均有硬上限,stderr 原文不得写入错误或日志,只记录固定分类、总字节数、SHA-256 与可取得的退出状态。Runner 正常、强制、watchdog 退出显式关池;Linux child 绑定 parent-death signal,避免 Runner 被强杀后遗留带凭据孤儿进程。 +- DirectProject 会话恢复补充:Codex thread 继续使用 `ephemeral=true`,不保存或恢复 Codex 原生 thread。项目对话 `.agent/conversations/project.jsonl` 是唯一聊天事实源;Direct 新消息由 AGC 读取全部 `user`、`assistant`、`tool` 行,按原顺序渲染为简单的 `user:` / `assistant:` / `tool:` 文本后,再追加本次新 user 请求发送给新 thread。app-server 意外中断时,已收到的 partial 文本作为普通 `assistant` 消息追加,并在末尾写入 `unexpected interrupt happened here`;断开处理与下一次发送均可重复追加,但使用普通消息 `messageId` 幂等。项目打开不再根据“只有 user 没有 assistant”自动重发旧请求;Direct 不新增 retry 入口。Runtime Agent 的恢复合同保持独立,不消费项目 Direct 对话历史。 - 兼容迁移:已有 AppData 未写 `agentMode` 时,仅当全局及逐 Agent 都是 `openai_responses` 才迁入 app-server;任何 `openai_chat / anthropic` 路由保持 `provider`,防止项目自动恢复先于用户改配置而批量失败。新安装仍默认 app-server;已确认兼容 Responses 的旧端点可由用户显式切换且继续使用原 model/base URL/API Key。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.52。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a44724132..fd5ff54f3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -212,6 +212,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` 是聊天唯一事实源;Direct 新消息由 AGC 读取全部 `user`、`assistant`、`tool` 记录,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加,并在文本末尾追加 `unexpected interrupt happened here`;断开处理和下一次发送都可尝试写入,依赖普通 `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` 或静默继续。 -- 2.52.0 From 9e8b7d880ef04404bb6e5220877d368b752a912a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 18:00:18 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E8=AE=A9Direct=20Codex=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E6=90=BA=E5=B8=A6=E9=A1=B9=E7=9B=AE=E8=81=8A=E5=A4=A9=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=20=E8=AF=BB=E5=8F=96project.jsonl=E5=AE=8C=E6=95=B4us?= =?UTF-8?q?er=20assistant=20tool=E8=AE=B0=E5=BD=95=20=E6=96=B0=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=A6=86=E7=9B=96=E5=BD=93=E5=89=8D=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=B9=B6=E4=BF=9D=E7=95=99ephemeral=E7=BA=BF=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent/codex_app_server.rs | 52 +++++++++++++++++++ .../src-tauri/src/agent/direct_runtime.rs | 13 ++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 0fbb5a01f..1a45580fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -3149,6 +3149,23 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map_err(|error| error.to_string()) } +pub(crate) fn build_direct_codex_history_prompt( + root: &std::path::Path, + client_turn_id: &str, + current_prompt: &str, +) -> Result { + let conversation = read_local_conversation_for_session_at(root, None, None)?; + let current_message_id = format!("direct-codex:{client_turn_id}:user"); + let mut lines = conversation + .messages + .iter() + .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) + .map(|message| format!("{}: {}", message.role, message.content)) + .collect::>(); + lines.push(format!("user: {}", current_prompt.trim())); + Ok(lines.join("\n")) +} + /// Direct home-page chat never binds Codex to a user project. It gets a /// fresh isolated read-only workspace and a stable in-process thread so a /// normal conversation can continue without creating a project, assets, a @@ -3234,6 +3251,41 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result mod tests { use super::*; + #[test] + fn direct_history_prompt_replays_all_project_messages_in_order() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "history-project", "history").expect("init"); + for (role, content, message_id) in [ + ("user", "hello", "direct-codex:old:user"), + ("assistant", "partial\nunexpected interrupt happened here", "partial-id"), + ("tool", "file-read result", "tool-id"), + ("user", "stored raw request", "direct-codex:new-turn:user"), + ] { + append_local_conversation_message_for_session_idempotent_at( + root.path(), + None, + None, + LocalConversationMessage { + role: role.to_string(), + content: content.to_string(), + agent_id: None, + }, + message_id, + ) + .expect("append history"); + } + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + ) + .expect("build prompt"); + assert_eq!( + prompt, + "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" + ); + } + #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ 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 6189558e7..69d959195 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 @@ -4220,9 +4220,20 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; + let replay_prompt = match crate::agent::codex_app_server::build_direct_codex_history_prompt( + root, + &turn_id, + &user_prompt, + ) { + Ok(prompt) => prompt, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, - &user_prompt, + &replay_prompt, creation_type.as_deref(), Some(&turn_emitter), Some(&mut audit), -- 2.52.0 From bb51c2716b29da748ed571537668b94466d7f94c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 18:01:00 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E4=BF=AE=E6=AD=A3Direct=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E4=B8=AD=E6=96=AD=E4=B8=8A=E4=B8=8B=E6=96=87=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=20=E5=88=A0=E9=99=A4=E6=89=93=E5=BC=80=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E8=87=AA=E5=8A=A8=E9=87=8D=E5=8F=91=E6=97=A7=E6=B6=88?= =?UTF-8?q?=E6=81=AF=20=E6=8C=81=E4=B9=85=E5=8C=96partial=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E6=96=B0=E6=B6=88=E6=81=AF=E5=89=8D=E5=9B=9E=E6=94=BE?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ai-game-creator-shell/src/App.tsx | 136 +++++++++++-------------- 1 file changed, 57 insertions(+), 79 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f923269b7..3f524a8f1 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -293,56 +293,6 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -function directCodexConversationTurnId( - message: ChatMessage, - role: ChatMessage['role'], -) { - if (message.role !== role) { - return null; - } - const messageId = message.messageId?.trim() ?? ''; - const roleSuffix = `:${role}`; - if ( - !messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) || - !messageId.endsWith(roleSuffix) - ) { - return null; - } - const turnId = messageId.slice( - DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, - -roleSuffix.length, - ); - return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId) ? turnId : null; -} - -export function unansweredDirectCodexConversationTurn(messages: ChatMessage[]) { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (!message) { - continue; - } - const turnId = directCodexConversationTurnId(message, 'user'); - if (!turnId) { - continue; - } - const assistantMessageId = directCodexConversationMessageId( - turnId, - 'assistant', - ); - if ( - messages.some( - (candidate) => - candidate.role === 'assistant' && - candidate.messageId?.trim() === assistantMessageId, - ) - ) { - continue; - } - return { prompt: message.text, turnId }; - } - return null; -} - export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -540,6 +490,12 @@ export function App({ useState(null); const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); + const directCodexTransientReplyRef = useRef(''); + const directCodexInterruptedPartialRef = useRef<{ + projectPath: string; + text: string; + messageId: string; + } | null>(null); const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -579,6 +535,7 @@ export function App({ setDirectCodexProgress(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } @@ -592,6 +549,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; } @@ -1232,8 +1190,6 @@ export function App({ activeDirectCodexTurnRef.current = null; setDirectCodexProgress('处理失败,正在同步错误'); setDirectCodexProgressUpdatedAt(updatedAt); - setDirectCodexTransientReply(''); - setDirectCodexTransientReplyUpdatedAt(null); return; } if (payload.status === 'completed') { @@ -1245,6 +1201,7 @@ export function App({ } if (typeof payload.accumulatedText === 'string') { setDirectCodexTransientReply(payload.accumulatedText); + directCodexTransientReplyRef.current = payload.accumulatedText; setDirectCodexTransientReplyUpdatedAt(updatedAt); } }, @@ -1840,9 +1797,8 @@ export function App({ ); if (localProjectPathRef.current === nextProjectPath) { // Any history read started before this terminal append may hold - // a user-only snapshot. Invalidate it before releasing the - // in-memory claim so that stale hydration cannot replay the - // same billable Direct turn. + // A stale history snapshot may still be missing this terminal + // append. Invalidate it before releasing the in-memory claim. projectSupervisorHistoryLoadVersionRef.current += 1; } recoveredDirectCodexTurnClaimsRef.current.delete(claimKey); @@ -2698,9 +2654,6 @@ export function App({ projectConversation.messages, supervisorConversation?.messages ?? [], ); - const unansweredDirectTurn = directCodexProductRuntime - ? unansweredDirectCodexConversationTurn(conversationMessages) - : null; if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -2752,24 +2705,6 @@ export function App({ }); return nextConversationMessages; }); - if (unansweredDirectTurn) { - const claimKey = `${nextProjectPath}\u0000${unansweredDirectTurn.turnId}`; - void Promise.resolve().then(() => { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath || - activeDirectCodexTurnRef.current || - recoveredDirectCodexTurnClaimsRef.current.has(claimKey) - ) { - return; - } - recoveredDirectCodexTurnClaimsRef.current.add(claimKey); - void executeChatAgentReply({ - prompt: unansweredDirectTurn.prompt, - clientTurnId: unansweredDirectTurn.turnId, - }); - }); - } } catch (error) { if ( projectSupervisorHistoryLoadVersionRef.current !== loadVersion || @@ -5459,6 +5394,20 @@ export function App({ }, }, ); + const persistDirectPartialMessage = (messageId: string, text: string) => + directInvoke( + 'append_local_conversation_message', + { + projectPath: directProjectPath, + agentId: null, + messageId, + message: { + role: 'assistant', + content: `${text.trim()}\nunexpected interrupt happened here`, + agentId: null, + }, + }, + ); const recoveredDirectCodexTurnClaimKey = `${directProjectPath}\u0000${clientTurnId}`; recoveredDirectCodexTurnClaimsRef.current.add( recoveredDirectCodexTurnClaimKey, @@ -5473,9 +5422,21 @@ export function App({ setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); setDirectCodexProgressUpdatedAt(Date.now()); setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { + const interruptedPartial = directCodexInterruptedPartialRef.current; + if ( + interruptedPartial?.projectPath === directProjectPath && + interruptedPartial.text.trim() + ) { + await persistDirectPartialMessage( + interruptedPartial.messageId, + interruptedPartial.text, + ); + 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 @@ -5526,10 +5487,10 @@ export function App({ } } // Rust persists a successful Direct reply before returning Ok. The - // browser append is redundant, so the hydrated-turn claim can be + // browser append is redundant, so the in-memory turn claim can be // released without reopening the Provider side effect. Invalidate - // any user-only history snapshot captured before Rust committed the - // terminal reply first. + // any history snapshot captured before Rust committed the terminal + // reply first. if (localProjectPathRef.current === directProjectPath) { projectSupervisorHistoryLoadVersionRef.current += 1; } @@ -5565,6 +5526,23 @@ export function App({ '陶泥儿智能创作', true, ); + const partial = directCodexTransientReplyRef.current.trim(); + if (partial) { + const partialMessageId = + globalThis.crypto?.randomUUID?.() || + `direct-partial-${Date.now().toString(36)}`; + directCodexInterruptedPartialRef.current = { + projectPath: directProjectPath, + text: partial, + messageId: partialMessageId, + }; + try { + await persistDirectPartialMessage(partialMessageId, partial); + } catch { + // The next user send retries this idempotent append before + // constructing the replay prompt. + } + } try { await persistDirectAssistantMessage(visibleMessage); if (localProjectPathRef.current === directProjectPath) { -- 2.52.0 From 1b12b7b98e39063fe23ebf80a5f6a1d76f99c1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 18:08:39 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E9=99=90=E5=88=B6Direct=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=9B=9E=E6=94=BE=E5=88=B0=E6=96=B0=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=20=E4=BF=9D=E7=95=99=E8=BF=9B=E7=A8=8B=E5=86=85thread=E7=9A=84?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E8=BF=9E=E7=BB=AD=E5=AF=B9=E8=AF=9D=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=AD=E6=96=AD=E6=81=A2=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E9=87=8D=E5=BB=BA=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent/codex_app_server.rs | 41 +++++++++++++------ .../src-tauri/src/agent/direct_runtime.rs | 16 ++------ .../src/agent/runtime_driver/entrypoints.rs | 4 ++ .../shared-memory/decision-log.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 5 files changed, 39 insertions(+), 26 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 1a45580fe..6e1e985b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1417,6 +1417,11 @@ fn trust_isolated_game_creator_codex_workspace( } impl CodexAppServerConnection { + async fn has_thread_for(&self, snapshot: &AgentRuntimeProviderRequestSnapshot) -> bool { + let key = CodexNodeThreadKey::from(snapshot); + self.inner.threads.lock().await.contains_key(&key) + } + async fn acquire( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -3020,6 +3025,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( user_prompt, None, None, + None, ) .await } @@ -3034,6 +3040,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( root, system_prompt, user_prompt, + None, Some(observer), None, ) @@ -3090,6 +3097,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, + client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, ) -> Result { @@ -3129,11 +3137,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( }; let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; - let request = LlmRunRequest::single_turn(system_prompt, user_prompt) - .with_api_kind(api_kind) - .with_model(config.llm.model.clone()) - .with_request_timeout_ms(config.llm.request_timeout_ms) - .with_max_output_tokens(16_000); let connection = CodexAppServerConnection::acquire_at_workspace( &snapshot, &config.llm, @@ -3142,6 +3145,20 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map_err(|error| error.to_string())?; + let user_prompt = if connection.has_thread_for(&snapshot).await { + user_prompt + } else { + build_direct_codex_history_prompt( + &codex_root, + client_turn_id.unwrap_or("__none__"), + &user_prompt, + )? + }; + let request = LlmRunRequest::single_turn(system_prompt, user_prompt) + .with_api_kind(api_kind) + .with_model(config.llm.model.clone()) + .with_request_timeout_ms(config.llm.request_timeout_ms) + .with_max_output_tokens(16_000); connection .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) .await @@ -3257,7 +3274,11 @@ mod tests { init_local_game_project_at(root.path(), "history-project", "history").expect("init"); for (role, content, message_id) in [ ("user", "hello", "direct-codex:old:user"), - ("assistant", "partial\nunexpected interrupt happened here", "partial-id"), + ( + "assistant", + "partial\nunexpected interrupt happened here", + "partial-id", + ), ("tool", "file-read result", "tool-id"), ("user", "stored raw request", "direct-codex:new-turn:user"), ] { @@ -3274,12 +3295,8 @@ mod tests { ) .expect("append history"); } - let prompt = build_direct_codex_history_prompt( - root.path(), - "new-turn", - "new request", - ) - .expect("build prompt"); + let prompt = build_direct_codex_history_prompt(root.path(), "new-turn", "new request") + .expect("build prompt"); assert_eq!( prompt, "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" 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 69d959195..f27a8dce1 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 @@ -3835,6 +3835,7 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; let reply = if let Some(emitter) = turn_emitter { + let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); let mut has_streamed = false; let mut latest_accumulated_text = None; @@ -3856,6 +3857,7 @@ async fn run_direct_game_creator_turn_inner( root, system_prompt, prompt.to_string(), + Some(&client_turn_id), Some(&mut observer), audit, ) @@ -3866,6 +3868,7 @@ async fn run_direct_game_creator_turn_inner( system_prompt, prompt.to_string(), None, + None, audit, ) .await @@ -4220,20 +4223,9 @@ pub(crate) async fn chat_with_game_creator_direct_codex( return Err(error); } }; - let replay_prompt = match crate::agent::codex_app_server::build_direct_codex_history_prompt( - root, - &turn_id, - &user_prompt, - ) { - Ok(prompt) => prompt, - Err(error) => { - audit.finish(false); - return Err(error); - } - }; let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, - &replay_prompt, + &user_prompt, creation_type.as_deref(), Some(&turn_emitter), Some(&mut audit), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index bae479e11..e8c207db6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -93,6 +93,10 @@ impl DirectGameCreatorTurnUpdateEmitter { }, ); } + + pub(crate) fn turn_id(&self) -> &str { + &self.turn_id + } } pub(crate) fn start_game_creator_manifest_invalidation_event_sink( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 63f4a5cff..a1e20d8cd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7568,7 +7568,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 配置:有无 AGC LLM Key 都只支持 `openai_responses`;非空 Key 映射后 base URL 与逐 Agent model/effort 生效,Key 只经专用环境变量;空 Key 只桥接用户 Codex `auth.json`,移除继承环境 Key。`stream=true` 复用现有 durable final-reply delta;`webSearchEnabled=true`、`openai_chat / anthropic` 必须显式切换 `provider`,不得静默忽略已有 LLM 配置。 - 安全与恢复:app-server 使用隔离临时 `CODEX_HOME` 与 OS HOME,只桥接认证,不加载用户 MCP/config/skills/hooks;启动前关闭 web/multi-agent/shell/browser/plugin/image 等原生能力,固定 read-only、network off、never approval,AGC 是唯一 ToolHost。取消覆盖 turn-start 回包前窗口并只发送单 turn interrupt;已开始 turn 的连接/终态未知直接标记 reconciliation,明确 failed/interrupted 不自动重试。模式与 durable 指纹取同一配置快照。 - 资源与退出:pool 按实际凭据快照/base URL/API kind/CLI 版本和节点 run 身份隔离;空 AppData Key 只允许桥接一次性读取的有界 `auth.json` 快照,并用同一字节快照生成池指纹,宿主 `CODEX_API_KEY` 对 app-server 与一次性 CLI 都必须移除。节点进程与 thread 均有上限并只淘汰 inactive LRU;stdout NDJSON 与 stderr 无换行记录均有硬上限,stderr 原文不得写入错误或日志,只记录固定分类、总字节数、SHA-256 与可取得的退出状态。Runner 正常、强制、watchdog 退出显式关池;Linux child 绑定 parent-death signal,避免 Runner 被强杀后遗留带凭据孤儿进程。 -- DirectProject 会话恢复补充:Codex thread 继续使用 `ephemeral=true`,不保存或恢复 Codex 原生 thread。项目对话 `.agent/conversations/project.jsonl` 是唯一聊天事实源;Direct 新消息由 AGC 读取全部 `user`、`assistant`、`tool` 行,按原顺序渲染为简单的 `user:` / `assistant:` / `tool:` 文本后,再追加本次新 user 请求发送给新 thread。app-server 意外中断时,已收到的 partial 文本作为普通 `assistant` 消息追加,并在末尾写入 `unexpected interrupt happened here`;断开处理与下一次发送均可重复追加,但使用普通消息 `messageId` 幂等。项目打开不再根据“只有 user 没有 assistant”自动重发旧请求;Direct 不新增 retry 入口。Runtime Agent 的恢复合同保持独立,不消费项目 Direct 对话历史。 +- DirectProject 会话恢复补充:Codex thread 继续使用 `ephemeral=true`,不保存或恢复 Codex 原生 thread。项目对话 `.agent/conversations/project.jsonl` 是唯一聊天事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取全部 `user`、`assistant`、`tool` 行,按原顺序渲染为简单的 `user:` / `assistant:` / `tool:` 文本后,再追加本次新 user 请求发送给新 thread;已有 thread 的普通消息仍只发送新 user。app-server 意外中断时,已收到的 partial 文本作为普通 `assistant` 消息追加,并在末尾写入 `unexpected interrupt happened here`;断开处理与下一次发送均可重复追加,但使用普通消息 `messageId` 幂等。项目打开不再根据“只有 user 没有 assistant”自动重发旧请求;Direct 不新增 retry 入口。Runtime Agent 的恢复合同保持独立,不消费项目 Direct 对话历史。 - 兼容迁移:已有 AppData 未写 `agentMode` 时,仅当全局及逐 Agent 都是 `openai_responses` 才迁入 app-server;任何 `openai_chat / anthropic` 路由保持 `provider`,防止项目自动恢复先于用户改配置而批量失败。新安装仍默认 app-server;已确认兼容 Responses 的旧端点可由用户显式切换且继续使用原 model/base URL/API Key。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.52。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index fd5ff54f3..e19db9ecc 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -212,7 +212,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` 是聊天唯一事实源;Direct 新消息由 AGC 读取全部 `user`、`assistant`、`tool` 记录,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加,并在文本末尾追加 `unexpected interrupt happened here`;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取全部 `user`、`assistant`、`tool` 记录,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加,并在文本末尾追加 `unexpected interrupt happened here`;断开处理和下一次发送都可尝试写入,依赖普通 `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` 或静默继续。 -- 2.52.0 From 75875a0fadf1b141c4cc17ee4a22392742f03675 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 19:22:10 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9A=84Direct=E9=87=8D=E8=AF=95=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除已删除helper的测试导入。 删除已废弃的user-only自动重发用例。 --- .../appSurface/project-conversation.suite.ts | 65 +------------------ 1 file changed, 1 insertion(+), 64 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index c6d0fbe0d..147b5f7ce 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,7 +1,4 @@ -import { - isDirectCodexTurnAlreadyRunningError, - unansweredDirectCodexConversationTurn, -} from '../../src/App'; +import { isDirectCodexTurnAlreadyRunningError } from '../../src/App'; import { act, agentRuntimeUserInputRequest, @@ -26,66 +23,6 @@ import { } from './harness'; export function registerProjectConversationTests() { - it('replays only the latest unanswered Direct Codex turn with its original stable identity', () => { - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '请重新生成美术', - messageId: 'direct-codex:stable-turn-001:user', - }, - ]), - ).toEqual({ - prompt: '请重新生成美术', - turnId: 'stable-turn-001', - }); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '请重新生成美术', - messageId: 'direct-codex:stable-turn-001:user', - }, - { - role: 'assistant', - text: '已完成', - messageId: 'direct-codex:stable-turn-001:assistant', - }, - ]), - ).toBeNull(); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '恢复较早的未回答回合', - messageId: 'direct-codex:stable-turn-older:user', - }, - { - role: 'user', - text: '较新的已回答回合', - messageId: 'direct-codex:stable-turn-newer:user', - }, - { - role: 'assistant', - text: '较新的回复', - messageId: 'direct-codex:stable-turn-newer:assistant', - }, - ]), - ).toEqual({ - prompt: '恢复较早的未回答回合', - turnId: 'stable-turn-older', - }); - expect( - unansweredDirectCodexConversationTurn([ - { - role: 'user', - text: '伪造回合', - messageId: 'direct-codex:../unsafe:user', - }, - ]), - ).toBeNull(); - }); - it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => { expect( isDirectCodexTurnAlreadyRunningError( -- 2.52.0 From b1a9a296e8870a21d0707579c7ca348f4f416d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 19:24:02 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E6=81=A2=E5=A4=8DDirect=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=80=81=E4=B8=B4=E6=97=B6=E5=9B=9E=E5=A4=8D=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败事件到达时清空界面中的临时回复。 保留ref中的partial供中断持久化流程使用。 --- apps/ai-game-creator-shell/src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3f524a8f1..a7cc70883 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1188,6 +1188,8 @@ export function App({ : Date.now(); if (payload.status === 'failed') { activeDirectCodexTurnRef.current = null; + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); setDirectCodexProgress('处理失败,正在同步错误'); setDirectCodexProgressUpdatedAt(updatedAt); return; -- 2.52.0 From b136a7c3464598f4658f37ee2ac05a4507f956f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 19:24:09 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E5=88=A0=E9=99=A4Direct=E6=89=93?= =?UTF-8?q?=E5=BC=80=E9=A1=B9=E7=9B=AE=E8=87=AA=E5=8A=A8=E9=87=8D=E5=8F=91?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除已废弃的hydrated Direct回合自动恢复场景。 保留当前新发送与历史回放语义的测试覆盖。 --- .../tests/appSurface/home.suite.ts | 297 ------------------ 1 file changed, 297 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a2fa4ed27..649d81d3f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2302,303 +2302,6 @@ export function registerHomeProjectCreationTests() { ), ).toHaveLength(1); }); - - it('releases a hydrated Direct Codex turn claim after an in-progress rejection so the same App can resume it later', async () => { - const projectPath = - 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\running-direct-project'; - const manifest = createGameCreationAppManifest( - 'running-direct-project', - '运行中直连项目', - ); - const stableTurnId = 'stable-running-turn-001'; - let directTurnCallCount = 0; - const persistedMessages: Array> = [ - { - schemaVersion: 'game-creator-conversation.v1', - role: 'user', - content: '继续完成运行中的项目', - agentId: null, - messageId: `direct-codex:${stableTurnId}:user`, - updatedAt: 1, - }, - ]; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - return manifest; - } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - directTurnCallCount += 1; - if (directTurnCallCount === 1) { - throw new Error( - 'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行', - ); - } - return '恢复后的最终回复'; - } - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - - expect(await screen.findByText('继续完成运行中的项目')).not.toBeNull(); - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - { - projectPath, - prompt: '继续完成运行中的项目', - clientTurnId: stableTurnId, - }, - ); - }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - expect(persistedMessages).toHaveLength(1); - expect( - invoke.mock.calls.filter( - ([command, args]) => - command === 'append_local_conversation_message' && - (args as Record | undefined)?.messageId === - `direct-codex:${stableTurnId}:assistant`, - ), - ).toHaveLength(0); - expect(screen.queryByText(/direct-codex-turn-already-running/)).toBeNull(); - - const directComposer = screen.getByLabelText('陶泥儿对话内容'); - fireEvent.change(directComposer, { target: { value: '/history' } }); - fireEvent.submit(directComposer.closest('form') as HTMLFormElement); - - expect(await screen.findByText('恢复后的最终回复')).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(2); - expect( - persistedMessages.filter( - (message) => - message.messageId === `direct-codex:${stableTurnId}:assistant`, - ), - ).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: '恢复后的最终回复', - }), - ]); - }); - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_local_conversation', - ).length, - ).toBeGreaterThanOrEqual(2); - }); - - it.each([ - { - caseName: 'a successful reply', - firstError: null, - firstReply: '首次成功回复', - firstVisibleText: '首次成功回复', - }, - { - caseName: 'an ordinary error reply', - firstError: 'codex-app-server-error:unauthorized', - firstReply: null, - firstVisibleText: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态', - }, - ])( - 'reconciles a hydrated Direct Codex claim after persisting $caseName fails', - async ({ firstError, firstReply, firstVisibleText }) => { - const projectPath = - 'C:\\Users\\tester\\Documents\\Genarrative GameAgent\\retry-terminal-persistence'; - const manifest = createGameCreationAppManifest( - 'retry-terminal-persistence', - '终态持久化重试项目', - ); - const stableTurnId = 'stable-terminal-persistence-001'; - let directTurnCallCount = 0; - let allowAssistantPersistence = false; - let failedAssistantPersistenceCount = 0; - const directTurnIds: string[] = []; - const persistedMessages: Array> = [ - { - schemaVersion: 'game-creator-conversation.v1', - role: 'user', - content: '恢复终态持久化失败的回合', - agentId: null, - messageId: `direct-codex:${stableTurnId}:user`, - updatedAt: 1, - }, - ]; - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'get_local_game_manifest') { - return manifest; - } - if (command === 'read_local_conversation') { - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'append_local_permission_log') { - return {}; - } - if (command === 'read_project_permission_policy') { - return { - path: '.agent/policy.json', - policy: { deniedCommands: [], confirmCommands: [] }, - }; - } - if (command === 'append_local_conversation_message') { - const message = args?.message as Record; - if (message.role === 'assistant' && !allowAssistantPersistence) { - failedAssistantPersistenceCount += 1; - throw new Error('assistant conversation persistence unavailable'); - } - const messageId = String(args?.messageId ?? ''); - if ( - !messageId || - !persistedMessages.some( - (candidate) => candidate.messageId === messageId, - ) - ) { - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - ...message, - messageId, - updatedAt: Number( - message.updatedAt ?? persistedMessages.length + 1, - ), - }); - } - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [...persistedMessages], - }; - } - if (command === 'chat_with_game_creator_direct_codex') { - directTurnCallCount += 1; - directTurnIds.push(String(args?.clientTurnId ?? '')); - if (directTurnCallCount === 1) { - if (firstError) { - throw new Error(firstError); - } - persistedMessages.push({ - schemaVersion: 'game-creator-conversation.v1', - role: 'assistant', - content: firstReply, - agentId: null, - messageId: `direct-codex:${stableTurnId}:assistant`, - updatedAt: 2, - }); - return firstReply ?? ''; - } - allowAssistantPersistence = true; - return '恢复后的最终回复'; - } - throw new Error(`unexpected invoke ${command}`); - }, - ); - window.__TAURI__ = { core: { invoke } }; - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - projectSupervisorOnly: true, - }), - ); - - expect(await screen.findByText(firstVisibleText)).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(1); - expect(failedAssistantPersistenceCount).toBeGreaterThan( - firstError ? 1 : 0, - ); - expect(persistedMessages).toHaveLength(firstError ? 1 : 2); - }); - - const directComposer = screen.getByLabelText('陶泥儿对话内容'); - fireEvent.change(directComposer, { target: { value: '/history' } }); - fireEvent.submit(directComposer.closest('form') as HTMLFormElement); - - expect( - await screen.findByText(firstError ? '恢复后的最终回复' : firstReply!), - ).not.toBeNull(); - await waitFor(() => { - expect(directTurnCallCount).toBe(firstError ? 2 : 1); - expect(directTurnIds).toEqual( - firstError ? [stableTurnId, stableTurnId] : [stableTurnId], - ); - expect( - persistedMessages.filter( - (message) => - message.messageId === `direct-codex:${stableTurnId}:assistant`, - ), - ).toEqual([ - expect.objectContaining({ - role: 'assistant', - content: firstError ? '恢复后的最终回复' : firstReply, - }), - ]); - }); - }, - ); } export function registerRecentProjectsTests() { -- 2.52.0 From 8ed2f42b52abf8e5180847ffec0b820bcfdd1966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 20:12:41 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E5=8E=9F=E5=AD=90=E5=8C=96Direct?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=9B=9E=E6=94=BE?= =?UTF-8?q?=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让thread_for在同一锁内返回线程是否新建。 仅新建Direct线程时读取project.jsonl并注入历史。 保持已有线程只发送当前用户消息。 --- .../src-tauri/src/agent/codex_app_server.rs | 85 +++++++++++++------ 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 6e1e985b0..68b6472af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1417,11 +1417,6 @@ fn trust_isolated_game_creator_codex_workspace( } impl CodexAppServerConnection { - async fn has_thread_for(&self, snapshot: &AgentRuntimeProviderRequestSnapshot) -> bool { - let key = CodexNodeThreadKey::from(snapshot); - self.inner.threads.lock().await.contains_key(&key) - } - async fn acquire( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -1948,17 +1943,23 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, request: &LlmRunRequest, llm: &GameCreatorLlmConfig, - ) -> Result { + ) -> Result<(CodexThreadLease, bool), platform_llm::LlmError> { let key = CodexNodeThreadKey::from(snapshot); let mut threads = self.inner.threads.lock().await; + // The caller holds `turn_gate` while invoking this method. Returning + // the creation bit from the same threads lock keeps the replay + // decision atomic with thread reuse/creation. if let Some(entry) = threads.get_mut(&key) { entry.active_uses = entry.active_uses.saturating_add(1); entry.last_used = next_game_creator_codex_app_server_usage_tick(); - return Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id: entry.thread_id.clone(), - }); + return Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id: entry.thread_id.clone(), + }, + false, + )); } if threads.len() >= GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX { let evict_key = threads @@ -2020,11 +2021,14 @@ impl CodexAppServerConnection { active_uses: 1, }, ); - Ok(CodexThreadLease { - connection: self.clone(), - key, - thread_id, - }) + Ok(( + CodexThreadLease { + connection: self.clone(), + key, + thread_id, + }, + true, + )) } async fn register_turn(&self, turn_id: &str) -> mpsc::UnboundedReceiver { @@ -2075,6 +2079,7 @@ impl CodexAppServerConnection { snapshot, llm, request, + None, on_agent_message_delta, None, None, @@ -2087,14 +2092,37 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, request: LlmRunRequest, + 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 { let _turn_guard = self.inner.turn_gate.lock().await; - let thread_lease = self.thread_for(snapshot, &request, llm).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; + if thread_created && self.inner.workspace_mode.uses_direct_conversation() { + // DirectProject intentionally replays the complete append-only + // project history. AGC owns that durable fact source; keeping + // the replay lossless is the product contract even though a + // future context-budget policy may need to change this choice. + let current_prompt = direct_codex_current_user_prompt(&request).to_string(); + let history_prompt = build_direct_codex_history_prompt( + &self.inner.workspace_path, + direct_client_turn_id.unwrap_or("__none__"), + ¤t_prompt, + ) + .map_err(platform_llm::LlmError::InvalidRequest)?; + if let Some(message) = request + .messages + .iter_mut() + .rev() + .find(|message| message.role == LlmMessageRole::User) + { + message.content = history_prompt; + } + } let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) } else { @@ -3145,22 +3173,21 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map_err(|error| error.to_string())?; - let user_prompt = if connection.has_thread_for(&snapshot).await { - user_prompt - } else { - build_direct_codex_history_prompt( - &codex_root, - client_turn_id.unwrap_or("__none__"), - &user_prompt, - )? - }; let request = LlmRunRequest::single_turn(system_prompt, user_prompt) .with_api_kind(api_kind) .with_model(config.llm.model.clone()) .with_request_timeout_ms(config.llm.request_timeout_ms) .with_max_output_tokens(16_000); connection - .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit) + .run_turn_with_direct_observer( + &snapshot, + &config.llm, + request, + client_turn_id, + None, + observer, + audit, + ) .await .map(|value| value.text) .map_err(|error| error.to_string()) @@ -3173,6 +3200,9 @@ pub(crate) fn build_direct_codex_history_prompt( ) -> Result { let conversation = read_local_conversation_for_session_at(root, None, None)?; let current_message_id = format!("direct-codex:{client_turn_id}:user"); + // The deliberately simple role-prefix format is part of the Direct + // replay contract. Do not introduce an envelope or implicit escaping + // here without updating the persisted-history compatibility decision. let mut lines = conversation .messages .iter() @@ -4622,6 +4652,7 @@ while IFS= read -r line; do :; done &test_snapshot(), &llm, tool_request(), + None, Some(&mut on_delta), Some(&mut observer), None, -- 2.52.0 From ae8d51500bf77805c7c5cb20ab0d763928d05105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 20:12:56 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E8=AE=B0=E5=BD=95Direct=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E6=9C=89=E6=84=8F=E8=AE=BE=E8=AE=A1=E5=8F=96=E8=88=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在partial持久化处说明普通assistant与中断标记语义。 在技术方案和决策日志记录全量回放与简单前缀的设计原因。 --- apps/ai-game-creator-shell/src/App.tsx | 3 +++ docs/project-memory/shared-memory/decision-log.md | 5 +++++ .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index a7cc70883..1d1431116 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -5405,6 +5405,9 @@ export function App({ messageId, message: { role: 'assistant', + // An interrupted partial is intentionally a normal assistant + // record so replay sees exactly what Codex emitted before the + // disconnect; the marker is product data, not UI metadata. content: `${text.trim()}\nunexpected interrupt happened here`, agentId: null, }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a1e20d8cd..b346d5071 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7893,3 +7893,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - `autonomous-game-build` 中,manifest `dependencies` 只作为上下文,不阻塞 ready;代码、设计、美术、音频和发布任务允许并行启动,child 不依赖固定回执顺序或固定 run 身份才能推进。 - 任务最终状态不再提前绑定平台画布、preview、static smoke 或发布产物检查;这些内容不参与该档位的完成判定,也不会因缺失而重置已完成任务。父 run 在任务图进入终态后直接收束并回复。 - 本档位仍沿用现有项目根和工具权限边界;本次调整只解除流程编排与平台产物验收前置,不新增第二套任务系统。 + +## 2026-09-02 DirectProject replay 取舍与 thread 原子判定 + +- DirectProject 的全量 replay、简单 `user:` / `assistant:` / `tool:` 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)都是有意的当前产品合同:分别保证 AGC JSONL 事实源无损重建、保持 prompt 形状稳定且不引入 envelope breaking change、让模型明确知道上次输出在中断处结束。后续若调整任一项,必须先更新恢复合同与兼容策略。 +- replay 与 thread 创建必须使用同一临界区结果。`turn_gate` 内的 `thread_for()` 原子返回 `(CodexThreadLease, created)`;只有 `created=true` 时才读取项目 JSONL 并构造历史 prompt,复用已有 thread 时只发送当前 user,避免并发首请求重复注入历史。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e19db9ecc..085b73844 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -212,7 +212,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` 是聊天唯一事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取全部 `user`、`assistant`、`tool` 记录,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。app-server 意外中断时,已收到的 partial 文本按普通 `assistant` 消息追加,并在文本末尾追加 `unexpected interrupt happened here`;断开处理和下一次发送都可尝试写入,依赖普通 `messageId` 幂等。项目打开只读取历史,不因 user-only 记录自动重发;Direct 不提供 retry 入口。Runtime Agent 继续使用独立的 runtime/context 恢复链路,不读取 DirectProject 对话作为原生 thread history。 +- DirectProject replay:Codex thread 仍保持 `ephemeral=true`。`.agent/conversations/project.jsonl` 是聊天唯一事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取全部 `user`、`assistant`、`tool` 记录,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。这里的三项取舍是有意的产品合同:全量 replay 保证 AGC 事实源无损恢复;简单 role 前缀保持 prompt 形状稳定、避免引入 breaking envelope;partial 作为普通 `assistant` 记录保存并带 `unexpected interrupt happened here`,让模型明确知道上次输出在断开处结束,而不额外引入 UI metadata。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` 或静默继续。 -- 2.52.0 From 877724d7bc5d17652ba5d8833912ef9122809e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 2 Sep 2026 20:14:53 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E4=BF=9D=E6=8C=81Direct=E8=A7=82?= =?UTF-8?q?=E5=AF=9F=E5=99=A8=E8=B0=83=E7=94=A8=E6=8E=A5=E5=8F=A3=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留原有run_turn_with_direct_observer参数形状。 将历史回放参数收口到内部专用入口。 --- .../src-tauri/src/agent/codex_app_server.rs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 68b6472af..84b23aba9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2079,7 +2079,6 @@ impl CodexAppServerConnection { snapshot, llm, request, - None, on_agent_message_delta, None, None, @@ -2088,6 +2087,27 @@ impl CodexAppServerConnection { } async fn run_turn_with_direct_observer( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + 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 { + self.run_turn_with_direct_observer_and_history( + snapshot, + llm, + request, + None, + on_agent_message_delta, + direct_observer, + audit, + ) + .await + } + + async fn run_turn_with_direct_observer_and_history( &self, snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -3179,7 +3199,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .with_request_timeout_ms(config.llm.request_timeout_ms) .with_max_output_tokens(16_000); connection - .run_turn_with_direct_observer( + .run_turn_with_direct_observer_and_history( &snapshot, &config.llm, request, @@ -4652,7 +4672,6 @@ while IFS= read -r line; do :; done &test_snapshot(), &llm, tool_request(), - None, Some(&mut on_delta), Some(&mut observer), None, -- 2.52.0 From 48a2723527f10921a739261d037c0f8258aa8964 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 10:42:29 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89Direct=E5=8E=86=E5=8F=B2=E6=A0=B9=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent/codex_app_server.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 84b23aba9..9b8dcb4f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2100,6 +2100,7 @@ impl CodexAppServerConnection { llm, request, None, + None, on_agent_message_delta, direct_observer, audit, @@ -2112,6 +2113,7 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, request: LlmRunRequest, + direct_history_root: Option<&std::path::Path>, 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)>, @@ -2128,8 +2130,9 @@ impl CodexAppServerConnection { // the replay lossless is the product contract even though a // future context-budget policy may need to change this choice. let current_prompt = direct_codex_current_user_prompt(&request).to_string(); + let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); let history_prompt = build_direct_codex_history_prompt( - &self.inner.workspace_path, + history_root, direct_client_turn_id.unwrap_or("__none__"), ¤t_prompt, ) @@ -3203,6 +3206,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( &snapshot, &config.llm, request, + Some(&codex_root), client_turn_id, None, observer, -- 2.52.0 From 8a679d7011527d3681be69a5dab1d62eaefc1dd8 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 11:44:29 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Direct=20replay=20?= =?UTF-8?q?=E6=9C=89=E7=95=8C=E6=BB=91=E5=8A=A8=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectProject replay 按上下文预算保留最新连续历史 超预算时仅派生裁剪 prompt,不修改 project.jsonl 补充超大历史与当前请求超限测试 --- .../src-tauri/src/agent/codex_app_server.rs | 227 +++++++++++++++++- 1 file changed, 218 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 9b8dcb4f3..99e7ef3fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2125,16 +2125,18 @@ impl CodexAppServerConnection { let thread_id = thread_lease.thread_id.clone(); let mut request = request; if thread_created && self.inner.workspace_mode.uses_direct_conversation() { - // DirectProject intentionally replays the complete append-only - // project history. AGC owns that durable fact source; keeping - // the replay lossless is the product contract even though a - // future context-budget policy may need to change this choice. + // DirectProject owns the append-only project history in AGC. The + // replay builder derives a bounded prompt without mutating that + // durable fact source, so a new ephemeral thread can recover the + // newest contiguous context within the model budget. let current_prompt = direct_codex_current_user_prompt(&request).to_string(); let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); let history_prompt = build_direct_codex_history_prompt( history_root, direct_client_turn_id.unwrap_or("__none__"), ¤t_prompt, + &request, + llm, ) .map_err(platform_llm::LlmError::InvalidRequest)?; if let Some(message) = request @@ -3217,24 +3219,153 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map_err(|error| error.to_string()) } +/// Builds the prompt used when a new DirectProject thread needs replay. +/// +/// The JSONL history remains immutable; only the derived prompt is bounded. +/// When the full request exceeds the replay target, the oldest contiguous +/// records are omitted and a plain `system:` marker is prepended. pub(crate) fn build_direct_codex_history_prompt( root: &std::path::Path, client_turn_id: &str, current_prompt: &str, + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, ) -> Result { let conversation = read_local_conversation_for_session_at(root, None, None)?; let current_message_id = format!("direct-codex:{client_turn_id}:user"); // The deliberately simple role-prefix format is part of the Direct // replay contract. Do not introduce an envelope or implicit escaping // here without updating the persisted-history compatibility decision. - let mut lines = conversation + let lines = conversation .messages .iter() .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) .map(|message| format!("{}: {}", message.role, message.content)) .collect::>(); - lines.push(format!("user: {}", current_prompt.trim())); - Ok(lines.join("\n")) + let current_line = format!("user: {}", current_prompt.trim()); + let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); + let target_budget = direct_codex_replay_target_budget(llm, base_request)?; + if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { + return Ok(full_prompt); + } + + // The persisted project conversation is immutable. We only derive a + // bounded prompt for this replay, keeping the newest contiguous records. + let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; + let mut selected_reversed = Vec::new(); + for line in lines.iter().rev() { + let mut candidate_reversed = selected_reversed.clone(); + candidate_reversed.push(line.as_str()); + let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); + let candidate_prompt = format_direct_codex_replay_prompt( + &candidate_lines, + ¤t_line, + Some(omission_marker), + ); + if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { + selected_reversed.push(line.as_str()); + } else { + break; + } + } + + let selected_lines = selected_reversed.iter().rev().copied().collect::>(); + let marked_prompt = + format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); + if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { + return Ok(marked_prompt); + } + + // If the marker itself would push the request over the target, preserve + // the current user request and omit only the marker. + let current_only_prompt = + format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); + if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { + return Ok(current_only_prompt); + } + direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) +} + +const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = + "system: Earlier conversation history was omitted due to context budget."; + +fn format_direct_codex_replay_prompt( + history_lines: &[impl AsRef], + current_line: &str, + omission_marker: Option<&str>, +) -> String { + let mut lines = Vec::with_capacity(history_lines.len() + 2); + if let Some(marker) = omission_marker { + lines.push(marker.to_string()); + } + lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); + lines.push(current_line.to_string()); + lines.join("\n") +} + +fn direct_codex_replay_target_budget( + llm: &GameCreatorLlmConfig, + request: &LlmRunRequest, +) -> Result { + const SAFETY_MARGIN_TOKENS: u64 = 4_096; + let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); + let hard_budget = llm + .context_window_tokens + .checked_sub(max_output_tokens) + .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) + .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; + Ok(llm.auto_compact_token_limit.min(hard_budget)) +} + +fn direct_codex_replay_estimate( + base_request: &LlmRunRequest, + prompt: &str, +) -> Result { + let mut request = base_request.clone(); + let user = request + .messages + .iter_mut() + .rev() + .find(|message| message.role == LlmMessageRole::User) + .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; + user.content = prompt.to_string(); + Ok(request) +} + +fn direct_codex_replay_prompt_fits( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, + target_budget: u64, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + if estimated > target_budget { + return Ok(false); + } + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(true) +} + +fn direct_codex_replay_validate_context_budget( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(prompt.to_string()) } /// Direct home-page chat never binds Codex to a user project. It gets a @@ -3349,14 +3480,92 @@ mod tests { ) .expect("append history"); } - let prompt = build_direct_codex_history_prompt(root.path(), "new-turn", "new request") - .expect("build prompt"); + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &test_llm(), + ) + .expect("build prompt"); assert_eq!( prompt, "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" ); } + #[test] + fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "window-project", "window").expect("init"); + for (role, content, message_id) in [ + ("user", "old ".repeat(1_000), "old-user"), + ("assistant", "middle ".repeat(100), "middle-assistant"), + ("tool", "newest ".repeat(100), "newest-tool"), + ] { + append_local_conversation_message_for_session_idempotent_at( + root.path(), + None, + None, + LocalConversationMessage { + role: role.to_string(), + content, + agent_id: None, + }, + message_id, + ) + .expect("append history"); + } + let mut llm = test_llm(); + llm.auto_compact_token_limit = 800; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let before = + std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &llm, + ) + .expect("build prompt"); + let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + + assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); + assert!(prompt.contains("tool: newest")); + assert!(!prompt.contains("user: old")); + assert!(prompt.ends_with("user: new request")); + assert_eq!(before, after); + } + + #[test] + fn direct_history_prompt_rejects_current_request_that_exceeds_context() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); + let mut llm = test_llm(); + llm.context_window_tokens = 5_000; + llm.auto_compact_token_limit = 1_000; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(100); + let error = build_direct_codex_history_prompt( + root.path(), + "new-turn", + &"request ".repeat(10_000), + &request, + &llm, + ) + .expect_err("oversized request should fail"); + assert!(error.contains("Direct replay 请求")); + } + #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [ -- 2.52.0 From e73d04db0030420b85698a39adea53e204b26bab 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 11:44:38 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E8=A1=A5=E5=85=85=20Direct=20replay=20?= =?UTF-8?q?=E6=9C=89=E7=95=8C=E5=8E=86=E5=8F=B2=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录不可变 project.jsonl 与滑动窗口恢复约定 说明 system 标记、预算边界和超限失败语义 --- docs/project-memory/shared-memory/decision-log.md | 6 ++++++ .../【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f63e077d4..254ae7cd3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7907,6 +7907,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - DirectProject 的全量 replay、简单 `user:` / `assistant:` / `tool:` 前缀和普通 assistant partial(末尾 `unexpected interrupt happened here`)都是有意的当前产品合同:分别保证 AGC JSONL 事实源无损重建、保持 prompt 形状稳定且不引入 envelope breaking change、让模型明确知道上次输出在中断处结束。后续若调整任一项,必须先更新恢复合同与兼容策略。 - replay 与 thread 创建必须使用同一临界区结果。`turn_gate` 内的 `thread_for()` 原子返回 `(CodexThreadLease, created)`;只有 `created=true` 时才读取项目 JSONL 并构造历史 prompt,复用已有 thread 时只发送当前 user,避免并发首请求重复注入历史。 +## 2026-09-03 DirectProject replay 有界滑动窗口 + +- DirectProject 继续以 `.agent/conversations/project.jsonl` 作为不可变、append-only 唯一事实源;窗口只生成本次恢复请求的派生 prompt,不写回 JSONL,不创建 Runtime compaction summary 或 sidecar。 +- 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。 +- 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。 + ## 2026-09-01 UI 编辑器代码导出与填充预览边界 - UI 编辑器导出的 `ui/generated-*.js` 是派生本地产物。代码生成只写文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不能把生成文件写入冒充项目 mutation。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7caeea088..b3b8039d2 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` 是聊天唯一事实源;仅当 app-server 连接没有可用的项目 thread(通常是进程重启或 thread 被淘汰)时,AGC 才读取全部 `user`、`assistant`、`tool` 记录,按原顺序渲染为简单 `user:` / `assistant:` / `tool:` 行,再追加本次新 user request,发送给新建 thread;已有 thread 的普通消息仍只发送新 user。这里的三项取舍是有意的产品合同:全量 replay 保证 AGC 事实源无损恢复;简单 role 前缀保持 prompt 形状稳定、避免引入 breaking envelope;partial 作为普通 `assistant` 记录保存并带 `unexpected interrupt happened here`,让模型明确知道上次输出在断开处结束,而不额外引入 UI metadata。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 事实源;仅当 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` 或静默继续。 -- 2.52.0 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 14/14] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20DirectProject=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E4=B8=8A=E4=B8=8B=E6=96=87=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20=E5=9B=9E=E5=90=88=E5=BC=80=E5=A7=8B=E5=89=8D?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E6=8C=81=E4=B9=85=E5=8C=96=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=20user=20prompt=20=E9=99=84=E4=BB=B6=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E7=BB=9F=E4=B8=80=E7=94=B1=20Rust=20=E5=86=99?= =?UTF-8?q?=E5=85=A5=E8=A7=84=E8=8C=83=E5=8C=96=E5=8E=86=E5=8F=B2=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=8E=9F=E5=A7=8B=E6=B6=88=E6=81=AF=E5=86=B2?= =?UTF-8?q?=E7=AA=81=20=E8=A1=A5=E5=85=85=E9=87=8D=E5=90=AF=E5=90=8E?= =?UTF-8?q?=E5=A4=9A=E8=BD=AE=20user/assistant=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=B5=8B=E8=AF=95=20=E5=90=8C=E6=AD=A5=20Dir?= =?UTF-8?q?ectProject=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` 或静默继续。 -- 2.52.0