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 0ea85c3b7..a9e238e02 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 @@ -960,7 +960,8 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); - let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?; + let (mcp_url, mcp_token) = + start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; direct_turn_trace("claude-mcp-loopback-ready"); let _mcp_guard = ExternalMcpStopGuard; let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 30a7ad3bb..a3a32f57f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -4236,8 +4236,9 @@ pub(crate) async fn start_direct_tool_bridge( pub(crate) async fn start_external_client_tool_bridge( root: &Path, controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result { - start_tool_bridge_for_source(root, controlled_web_search, false).await + start_tool_bridge_for_source(root, controlled_web_search, direct_turn_execution).await } async fn start_tool_bridge_for_source( @@ -4518,6 +4519,18 @@ mod tests { } } + #[tokio::test] + async fn external_client_bridge_can_be_enabled_for_a_direct_turn() { + let temporary = tempfile::tempdir().unwrap(); + init_local_game_project_at(temporary.path(), "external-direct-turn", "外部直连回合") + .unwrap(); + let bridge = start_external_client_tool_bridge(temporary.path(), false, true) + .await + .expect("direct Claude bridge"); + assert!(bridge.state.direct_turn_execution); + drop(bridge); + } + #[tokio::test] async fn host_cancellation_releases_queued_image_without_a_new_post() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 50ff3abf5..11275387c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -41,6 +41,7 @@ tokio::task_local! { } pub(crate) struct ExternalMcpServer { + _direct_turn_guard: Option, _bridge: super::direct_tool_bridge::DirectToolBridge, task: tokio::task::JoinHandle<()>, } @@ -1759,6 +1760,21 @@ async fn handle_external_mcp_http_request( pub(crate) async fn start_external_mcp_loopback( root: &Path, controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, false).await +} + +pub(crate) async fn start_external_mcp_loopback_for_direct_turn( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, true).await +} + +async fn start_external_mcp_loopback_with_mode( + root: &Path, + controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result<(String, String), String> { let root = validate_direct_tools_project_root(root)?; let session = current_platform_session() @@ -1771,9 +1787,17 @@ pub(crate) async fn start_external_mcp_loopback( let address = listener .local_addr() .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; - let bridge = - super::direct_tool_bridge::start_external_client_tool_bridge(&root, controlled_web_search) - .await?; + let bridge = super::direct_tool_bridge::start_external_client_tool_bridge( + &root, + controlled_web_search, + direct_turn_execution, + ) + .await?; + let direct_turn_guard = if direct_turn_execution { + Some(bridge.begin_user_turn()?) + } else { + None + }; let state = ExternalMcpHttpState { bridge_url: bridge.url().to_string(), root, @@ -1798,6 +1822,7 @@ pub(crate) async fn start_external_mcp_loopback( drop(previous); } *guard = Some(ExternalMcpServer { + _direct_turn_guard: direct_turn_guard, _bridge: bridge, task, }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 75a6d0d4f..67ff83da2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6289,7 +6289,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 - **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 - **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 7(内部 Claude MCP 桥误用外部只读模式)**:Claude Code sidecar 使用的 loopback MCP 原本调用 `start_external_client_tool_bridge(..., false)`,桥状态 `direct_turn_execution=false` 且没有 `begin_user_turn()` 授权;`agc_register_delivery_contract`、`agc_delivery_status`、`agc_update_plan` 每次都会返回 `ToolRequiresDirectTurn`,模型收到错误后又重复注册计划,最终陷入反馈死循环直到超时。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。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` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`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`。