修复 CC 模式无工具与重试静默两处问题
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

把 DirectProject CC 回合载荷抽成纯函数并加单测,断言 mcpServers/sessionKey 接线存在,防止工具能力被静默丢掉的构建回归
把 Claude Agent SDK 的 system/api_error 重试事件投影成可见过程行(HTTP 状态 + 上游正文 + 第 N/M 次)
前端把 role=system 的诊断条目按过程行渲染,不再被丢弃或误读为 assistant 回复
This commit is contained in:
kdletters
2026-10-07 15:08:09 +08:00
parent 65ff808942
commit 29bb3bd6a8
3 changed files with 216 additions and 19 deletions
@@ -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<String> {
fn value_detail(value: &serde_json::Value) -> Option<String> {
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<String>,
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();
@@ -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(
{
@@ -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',