From 29bb3bd6a84794c19e7f9b5618c8bb78394e7418 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:08:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20CC=20=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E6=97=A0=E5=B7=A5=E5=85=B7=E4=B8=8E=E9=87=8D=E8=AF=95=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E4=B8=A4=E5=A4=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 DirectProject CC 回合载荷抽成纯函数并加单测,断言 mcpServers/sessionKey 接线存在,防止工具能力被静默丢掉的构建回归 把 Claude Agent SDK 的 system/api_error 重试事件投影成可见过程行(HTTP 状态 + 上游正文 + 第 N/M 次) 前端把 role=system 的诊断条目按过程行渲染,不再被丢弃或误读为 assistant 回复 --- .../src-tauri/src/agent/claude_code_cli.rs | 206 ++++++++++++++++-- .../directThreadItemProjection.test.ts | 14 ++ .../directThreadItemProjection.ts | 15 +- 3 files changed, 216 insertions(+), 19 deletions(-) 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 f8daf3a20..7be937069 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 @@ -798,6 +798,58 @@ fn configure_claude_code_environment( Ok(()) } +/// 把 Claude Agent SDK 的 `system/api_error`(自动重试)事件投影成一条过程条目。 +/// +/// SDK 每次自动重试都会带上真实上游状态与正文,但此前这类事件在 `observe` 里被整段丢弃, +/// 结果是重试期间界面只有“正在处理”,直到重试终于成功(或耗尽)才落一条结果——用户看到 +/// 的现象就是“中间过程没有任何输出”。现场证据(2026-10-07 项目 gameagent-0514673b): +/// 14:23:34 起连续 10 次 409「泥点余额不足」重试,14:30:11 才出现唯一一条最终文本, +/// CC 会话轨迹里除这 10 条 `system/api_error` 外没有任何 assistant / tool 事件。 +/// +/// 返回 `(item_id, text)`:同一 attempt 复用同一个 id,重复事件只更新同一条过程行。 +fn claude_api_error_retry_item(event: &serde_json::Value) -> Option<(String, String)> { + let error = event.get("error")?; + let attempt = event + .get("retryAttempt") + .and_then(serde_json::Value::as_u64); + let max_retries = event.get("maxRetries").and_then(serde_json::Value::as_u64); + let status = error + .get("status") + .and_then(serde_json::Value::as_u64) + .or_else(|| event.get("status").and_then(serde_json::Value::as_u64)); + // 优先用 SDK 的 `formatted`(人读形态,例如 `409 泥点余额不足`);它缺失时才回落到 + // `message`(往往是整段上游 JSON,直接给用户看没有价值)。 + let detail = error + .get("formatted") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| claude_result_error_detail(error)) + .unwrap_or_default(); + let detail = match status { + Some(status) if detail.starts_with(&format!("{status} ")) => { + detail[format!("{status} ").len()..].to_string() + } + _ => detail, + }; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + detail.trim(), + 300, + ); + let mut text = String::from("上游请求失败"); + if let Some(status) = status { + text.push_str(&format!("(HTTP {status})")); + } + if !detail.trim().is_empty() { + text.push_str(&format!(":{}", detail.trim())); + } + if let (Some(attempt), Some(max)) = (attempt, max_retries) { + text.push_str(&format!(";正在自动重试(第 {attempt}/{max} 次)")); + } + let item_id = format!("direct-cc:api-error:{}", attempt.unwrap_or(0)); + Some((item_id, text)) +} + fn claude_result_error_detail(value: &serde_json::Value) -> Option { fn value_detail(value: &serde_json::Value) -> Option { match value { @@ -1762,6 +1814,25 @@ impl ClaudeCodeStreamState { Some("thinking_tokens") => { claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); } + Some("api_error") => { + // 重试/上游失败必须有一条可见的过程行:否则重试期间聊天区完全静默, + // 用户只会看到“正在处理”,拿不到 HTTP 状态与上游正文(见本函数上方注释)。 + if let Some((item_id, text)) = claude_api_error_retry_item(event) { + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "system".to_string(), + text, + at, + }, + at, + ), + ); + } + } _ => {} }, Some("result") => { @@ -1838,6 +1909,46 @@ impl ClaudeCodeStreamState { /// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback /// MCP,关闭所有内置工具。CC 的 SDK 事件在读取时投影到与 Codex 相同的 Thread Manager 合同。 +/// DirectProject 的 CC 回合请求载荷。 +/// +/// 模型的全部工具能力只靠 `mcpServers` 下发:宿主把 loopback MCP 端点交给 Claude Agent +/// SDK,SDK 再以 `mcp__agc__*` 暴露给模型。载荷一旦漏掉这项,模型就退化成"没有任何工具、 +/// 只能把代码贴在聊天里"的纯聊天执行器——现场证据:2026-10-07 安装版 0.1.227 的 CC 回合, +/// 会话轨迹里零 tool_use,模型原话"我这里没有可用的工具:❌ 写文件 / ❌ 读取你的项目目录"。 +/// 这里把载荷抽成纯函数并配单测:构建或合并再丢掉这项接线时,测试会直接失败。 +#[allow(clippy::too_many_arguments)] +fn claude_direct_turn_payload( + client_turn_id: Option<&str>, + session_key: &str, + resume: Option, + user_prompt: &str, + system_prompt: &str, + cwd: &Path, + model: &str, + max_turns: u32, + mcp_url: &str, + mcp_token: &str, +) -> serde_json::Value { + serde_json::json!({ + "type": "turn", + "id": client_turn_id.unwrap_or("claude-direct-turn"), + "sessionKey": session_key, + "resume": resume, + "prompt": user_prompt, + "systemPrompt": system_prompt, + "cwd": cwd, + "model": model, + "maxTurns": max_turns, + "mcpServers": { + "agc": { + "type": "http", + "url": mcp_url, + "headers": { "Authorization": format!("Bearer {mcp_token}") } + } + } + }) +} + pub(crate) async fn direct_game_creator_claude_code_chat_at( root: &Path, llm: &GameCreatorLlmConfig, @@ -1864,24 +1975,18 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( .get(&session_key) .cloned(); direct_turn_trace("claude-sidecar-start"); - let payload = serde_json::json!({ - "type": "turn", - "id": client_turn_id.unwrap_or("claude-direct-turn"), - "sessionKey": format!("agc-direct:{}", session_key.display()), - "resume": resume, - "prompt": user_prompt, - "systemPrompt": system_prompt, - "cwd": root, - "model": llm.model.trim(), - "maxTurns": CLAUDE_DIRECT_MAX_TURNS, - "mcpServers": { - "agc": { - "type": "http", - "url": mcp_url, - "headers": { "Authorization": format!("Bearer {mcp_token}") } - } - } - }); + let payload = claude_direct_turn_payload( + client_turn_id, + &format!("agc-direct:{}", session_key.display()), + resume, + &user_prompt, + &system_prompt, + root, + llm.model.trim(), + CLAUDE_DIRECT_MAX_TURNS, + &mcp_url, + &mcp_token, + ); let mut stream_state = ClaudeCodeStreamState::default(); let mut on_event = |event: &serde_json::Value| { stream_state.observe(root, event, &mut observer); @@ -1980,6 +2085,71 @@ mod tests { request } + #[test] + fn claude_direct_turn_payload_wires_loopback_mcp_tools() { + let payload = claude_direct_turn_payload( + Some("turn-1"), + "agc-direct:C:/project", + None, + "做个废土风扫雷", + "系统提示", + Path::new("C:/project"), + "claude-opus-5-5", + 16, + "http://127.0.0.1:1234/mcp-abc", + "token-1", + ); + + assert_eq!(payload["type"], "turn"); + assert_eq!(payload["id"], "turn-1"); + assert_eq!(payload["sessionKey"], "agc-direct:C:/project"); + assert_eq!(payload["maxTurns"], 16); + // 工具能力只靠这一项下发:漏了就退化成“没有工具的纯聊天”, + // 模型再也无法写文件 / 跑构建(见函数上方现场记录)。 + assert_eq!(payload["mcpServers"]["agc"]["type"], "http"); + assert_eq!( + payload["mcpServers"]["agc"]["url"], + "http://127.0.0.1:1234/mcp-abc" + ); + assert_eq!( + payload["mcpServers"]["agc"]["headers"]["Authorization"], + "Bearer token-1" + ); + } + + #[test] + fn claude_api_error_retry_item_surfaces_status_and_upstream_body() { + let event = serde_json::json!({ + "type": "system", + "subtype": "api_error", + "error": { + "message": "409 {\"error\":{\"code\":\"MUD_POINTS_INSUFFICIENT\",\"message\":\"泥点余额不足\"}}", + "status": 409, + "formatted": "409 泥点余额不足" + }, + "retryAttempt": 3, + "maxRetries": 10, + "source": "request_retry" + }); + + let (item_id, text) = + claude_api_error_retry_item(&event).expect("api_error 事件必须投影出过程行"); + assert_eq!(item_id, "direct-cc:api-error:3"); + assert!(text.contains("HTTP 409"), "{text}"); + assert!(text.contains("泥点余额不足"), "{text}"); + assert!(text.contains("第 3/10 次"), "{text}"); + // 同一次尝试复用同一条过程行:事件重放不会多出一条。 + assert_eq!( + claude_api_error_retry_item(&event).map(|item| item.0), + Some(item_id) + ); + // 没有错误载荷的事件不投影,避免造出一条没有正文的失败行。 + assert!(claude_api_error_retry_item( + &serde_json::json!({ "type": "system", "subtype": "api_error" }) + ) + .is_none()); + } + #[test] fn claude_block_item_id_keeps_thinking_and_text_apart() { let mut state = ClaudeCodeStreamState::default(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.test.ts index 131b6dda5..a85dcb982 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.test.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.test.ts @@ -3,6 +3,20 @@ import { describe, expect, it } from 'vitest'; import { projectDirectThreadItem } from './directThreadItemProjection'; describe('DirectProject 工具条目投影', () => { + it('系统的上游重试条目按过程行渲染,不会被读成 assistant 回复', () => { + const entry = projectDirectThreadItem({ + itemType: 'message', + itemId: 'direct-cc:api-error:3', + role: 'system', + text: '上游请求失败(HTTP 409):泥点余额不足;正在自动重试(第 3/10 次)', + at: 5, + }); + + expect(entry?.kind).toBe('reasoning'); + expect(entry?.role).toBeNull(); + expect(entry?.text).toContain('泥点余额不足'); + }); + it('CC 无参数、无输出的工具在开始和完成后都保留卡片', () => { const started = projectDirectThreadItem( { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.ts index 429f41912..d6322a900 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadItemProjection.ts @@ -303,13 +303,26 @@ export function projectDirectThreadItem( switch (item.itemType) { case 'message': { + if (!item.text.trim()) return null; + if (item.role === 'system') { + // 宿主诊断条目(例如上游自动重试):不是模型正文,按过程行渲染, + // 既能让重试期间有可见输出,也不会被读成 assistant 的回复。 + return { + itemId, + kind: 'reasoning', + role: null, + text: item.text, + toolCall: null, + at: item.at, + }; + } const role = item.role === 'user' ? 'user' : item.role === 'assistant' ? 'assistant' : null; - if (!role || !item.text.trim()) return null; + if (!role) return null; return { itemId, kind: 'message',