diff --git a/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs b/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs index 16f819b25..7f6c7baf5 100644 --- a/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs +++ b/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs @@ -56,6 +56,12 @@ function buildOptions(request, controller) { if (request.sessionId) options.sessionId = request.sessionId; if (request.mcpServers && typeof request.mcpServers === 'object') { options.mcpServers = request.mcpServers; + // `dontAsk` 模式下没有显式放行的工具会被直接拒绝(真实案例:2026-10-03 cc 回合里模型想读 + // 项目文件,`agc_read_project_context` 被拒,用户只拿到一句"没有权限")。宿主暴露的 MCP + // 本身就是 AGC 的工具边界(权限策略在宿主侧执行),这里按服务器前缀整体放行。 + options.allowedTools = Object.keys(request.mcpServers).map( + (name) => `mcp__${name}`, + ); } if (request.outputSchema) { options.outputFormat = { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 7fd66c9fa..4709994ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -979,7 +979,27 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( direct_turn_trace("claude-parse-start"); let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer); match &parsed { - Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())), + Ok(text) => { + direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())); + // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 + // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 + // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 + let item_id = match client_turn_id { + Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"), + None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()), + }; + let item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{ "type": "output_text", "text": text }], + }); + if let Err(error) = append_direct_project_history_item_at(root, &item) { + eprintln!("[agc-cc-direct] persist assistant failed: {error}"); + direct_turn_trace("claude-parse-error"); + return Err(format!("写入本项目对话历史失败:{error}")); + } + } Err(error) => { // 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉, // 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index 1d3d004c9..61ca56880 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -35,6 +35,17 @@ pub(crate) struct DirectTurnTerminal { } impl DirectTurnTerminal { + /// 一次成功终态:只带 `status="completed"`,不带失败载荷。 + /// + /// 供给没有"深层终态出口"的执行器(cc / Claude Code sidecar)用:它们整轮成功返回后, + /// 线程仍被放行占用,必须由放行侧补写这条终态。 + pub(crate) fn completed() -> Self { + Self { + status: "completed".to_string(), + failure: None, + } + } + /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { let event = match self.failure { @@ -258,4 +269,15 @@ stderrClass=nonempty;stderrBytes=1000"; assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); assert!(!failure.message.trim().is_empty()); } + + #[test] + fn completed_terminal_carries_no_failure_payload() { + let terminal = DirectTurnTerminal::completed(); + assert_eq!(terminal.status, "completed"); + assert!(terminal.failure.is_none()); + assert!(matches!( + terminal.event(1_700_000_000_000, Some("item-1")), + ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + )); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index f8be8982e..cd700bfc9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -98,14 +98,17 @@ impl Drop for TurnReservation { // 不必只依赖 AppData 的应用日志。 let failure = DirectTurnError::TurnFailed { stage: DirectCodexFailureStage::CodeGeneration, + // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, + // 整行被替换成 ``,离线就只剩一个 + // 说不出原因的 HostDropped。 detail: format!( - "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}", + "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} tt={}", self.thread_id, self.token ), }; let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None); app_log!( - "agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}", + "agent.direct_turn.host_dropped threadId={} tt={} userItemId={} panicking={}", self.thread_id, self.token, self.user_item_id.as_deref().unwrap_or("-"), @@ -149,7 +152,7 @@ fn ensure_direct_turn_panic_hook() { .unwrap_or_else(|| "未知位置".to_string()); #[cfg(not(test))] app_log!( - "agent.direct_turn.panic threadId={} turnToken={} location={} payload={}", + "agent.direct_turn.panic threadId={} tt={} location={} payload={}", thread_id, token, location, @@ -270,6 +273,11 @@ async fn run_dispatched_direct_turn( Ok(_) => { // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 emitter.emit("completed", Some("none")); + // cc(Claude Code sidecar)执行器没有 app-server 那条"深层终态出口":整轮成功返回后 + // 线程仍被这条放行占用,不补写终态的话,占用对象 Drop 时的兜底会把一轮已经拿到回复的 + // 回合收成 `HostDropped`(真实案例:2026-10-03 `claude-parse-done chars=176` 之后立刻 + // `host_dropped panicking=false`)。codex 路径已写过终态,这里是空操作。 + reservation.finish_if_unfinished(DirectTurnTerminal::completed()); } Err(error) => { // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d0488a25a..876438088 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6269,3 +6269,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **注意**:日志脱敏标记包含 `credential`、`x-api-key`、`bearer `、`token=`、`api_key`,命中即整行替换成 ``。诊断行只能写 `source=`/`host=`/`auth=` 这类自查过的字段(第一版写成 `credential=bearer`,整行被吃掉过一次)。 - **验证**:`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_code_cli::tests::` 11 passed,覆盖平台优先、自定义端点保留配置、未登录时不回落本机环境且报「先登录」、baseUrl `/v1` 归一化、主机名诊断、sidecar 环境隔离(`USERPROFILE`/`CLAUDE_CONFIG_DIR` 指向隔离目录且不带本机 `ANTHROPIC_*`);另用本地假 Anthropic 端点跑通真 sidecar:`REQ HEAD /api/hello` → `REQ POST /v1/messages?beta=true auth=bearer` → `result=PROBE_OK`。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`、[`【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`](../../technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md)。 + +## 2026-10-03 cc 回合"模型已回复却报宿主任务提前结束":缺终态 + 缺落盘 + 工具被拒 + +- **现象**:Router 渠道恢复后,cc 回合能拿到真实回复(日志 `stage=claude-parse-done chars=176/208/289`),但紧接着就是 `runtime-unclassified` +「DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消)」,用户看到的仍是失败;项目对话历史里只有用户消息、没有助手消息;模型还会回一句「读项目文件的工具没有权限」。 +- **根因 1(缺终态)**:终态 `turn.completed` 的"深层出口"只在 codex app-server 那条路径里(`DirectTurnTerminalContext::write` → `complete_turn`)。cc 执行器(`direct_game_creator_claude_code_chat_at`)整轮成功返回后没有任何人写终态,于是 `thread_manager::dispatch` 里占用对象的兜底把一轮已经拿到回复的回合收成 `HostDropped`。诊断行 `agent.direct_turn.host_dropped … panicking=false` 且**没有** `agent.direct_turn.panic` 行,就是这条(不是 panic,是"没写终态就结束")。 +- **根因 2(缺落盘)**:assistant 回复只存在于 SDK 事件流里。codex 路径由 `finish_direct_project_collect_history` 写进 `.agent/conversations/project.jsonl`,cc 没有对应步骤,所以即使回合收口成功,UI 也读不到回复。 +- **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须把回复按 `{"type":"message","role":"assistant","id":"direct-codex::assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,落盘失败按回合失败收口;sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 +- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致;同一日志里不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。