统一 Claude Code 与 Codex 错误分类
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m46s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 5m5s
Project CI / Frontend tests (push) Successful in 4m36s
Project CI / Backend tests (push) Failing after 5m40s
Project CI / Repository checks (push) Failing after 4m32s
Project CI / AI game creator shell web tests (push) Successful in 2m54s
Project CI / Native shell tests (push) Successful in 8m37s

保留 Claude Code 的上游 HTTP 状态、超时、空响应和无效 JSON 语义

补充状态摘要、重试建议、定向回归测试与排障记录
This commit is contained in:
kdletters
2026-10-03 17:57:41 +08:00
parent 0b03d563c2
commit 8aee5db84e
5 changed files with 136 additions and 11 deletions
@@ -43,9 +43,9 @@ pub mod tool;
pub(crate) use art_manifest::*;
use claude_code_cli::*;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at,
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
game_creator_claude_code_cli_version_identity,
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
};
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
pub(crate) use codex_app_server::turn_error::*;
@@ -747,6 +747,46 @@ fn parse_usage(value: &serde_json::Value) -> Option<platform_llm::LlmTokenUsage>
})
}
/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。
///
/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别
/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` /
/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。
pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError {
if let Some(status_code) = claude_code_failure_status_code(&detail) {
return platform_llm::LlmError::Upstream {
status_code,
message: detail,
};
}
if detail.contains("Claude Agent SDK sidecar 回合超时") {
return platform_llm::LlmError::Timeout { attempts: 1 };
}
if detail.contains("Claude Code 缺少最终回复") {
return platform_llm::LlmError::EmptyResponse;
}
if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON")
{
return platform_llm::LlmError::Deserialize(detail);
}
platform_llm::LlmError::Transport(detail)
}
fn claude_code_failure_status_code(detail: &str) -> Option<u16> {
["Request rejected (", "HTTP "].iter().find_map(|marker| {
let tail = detail.split_once(marker)?.1;
let digits = tail
.chars()
.take_while(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 3 {
return None;
}
let status_code = digits.parse::<u16>().ok()?;
(100..=599).contains(&status_code).then_some(status_code)
})
}
fn parse_claude_code_result(
stdout: &[u8],
request: &LlmRunRequest,
@@ -758,7 +798,7 @@ fn parse_claude_code_result(
let detail = claude_result_error_detail(&value)
.map(|detail| format!(":{detail}"))
.unwrap_or_default();
return Err(platform_llm::LlmError::Transport(format!(
return Err(claude_code_failure_to_llm_error(format!(
"Claude Code 返回失败终态{detail}"
)));
}
@@ -1156,6 +1196,29 @@ mod tests {
assert!(error.to_string().contains("Authentication failed"));
}
#[test]
fn claude_failure_reuses_codex_error_categories() {
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Code 返回失败终态:API Error: Request rejected (429)".into()
),
platform_llm::LlmError::Upstream {
status_code: 429,
..
}
));
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into()
),
platform_llm::LlmError::Timeout { attempts: 1 }
));
assert!(matches!(
claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()),
platform_llm::LlmError::EmptyResponse
));
}
#[test]
fn parses_direct_stream_result_and_final_text() {
let mut observed = Vec::new();
@@ -5300,7 +5300,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
observer,
)
.await
.map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail)));
.map_err(|detail| {
TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail))
});
}
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
TurnError::EnvironmentNotReady(EnvironmentNotReady {
@@ -304,7 +304,7 @@ impl ModelCallKind {
native,
} => match native {
Some(native) => native.is_retryable(),
None => *status_code >= 500,
None => matches!(*status_code, 401 | 408 | 429 | 500..=599),
},
Self::PaidCreditsInsufficient => false,
Self::EmptyResponse => false,
@@ -322,9 +322,16 @@ impl ModelCallKind {
}
Self::EmptyResponse => Some("模型未返回内容"),
Self::PayloadInvalid { .. } => Some("模型回执无法解析"),
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
Self::RequestRejected { native } => {
native.as_ref().and_then(NativeKind::public_summary)
}
Self::UpstreamFailed {
status_code,
native,
} => native
.as_ref()
.and_then(NativeKind::public_summary)
.or_else(|| upstream_status_summary(*status_code)),
}
}
@@ -341,13 +348,40 @@ impl ModelCallKind {
Self::EmptyResponse | Self::PayloadInvalid { .. } => {
Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断")
}
Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => {
native.as_ref().and_then(NativeKind::recovery_hint)
}
Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint),
Self::UpstreamFailed {
status_code,
native,
} => native
.as_ref()
.and_then(NativeKind::recovery_hint)
.or_else(|| upstream_status_recovery_hint(*status_code)),
}
}
}
fn upstream_status_summary(status_code: u16) -> Option<&'static str> {
Some(match status_code {
401 => "上游服务拒绝认证(HTTP 401)",
403 => "上游服务拒绝访问(HTTP 403)",
408 => "上游请求超时(HTTP 408)",
429 => "上游服务返回 HTTP 429",
500..=599 => "上游服务暂时不可用",
_ => return None,
})
}
fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> {
Some(match status_code {
401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断",
403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断",
408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断",
429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断",
500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断",
_ => return None,
})
}
// ══════════════════════════════════════════════════════════════════════════════════════════
// 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列
// ══════════════════════════════════════════════════════════════════════════════════════════
@@ -1327,6 +1361,31 @@ mod tests {
assert!(!projected.is_model_repairable());
}
#[test]
fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() {
let rate_limited = TurnError::from_model_call(&LlmError::Upstream {
status_code: 429,
message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(),
});
assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429"));
assert_eq!(
rate_limited.recovery_hint(),
Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断")
);
assert!(rate_limited.is_retryable());
assert!(!rate_limited.is_model_repairable());
let unauthorized = TurnError::from_model_call(&LlmError::Upstream {
status_code: 401,
message: "Claude Code 返回失败终态:HTTP 401".into(),
});
assert_eq!(
unauthorized.public_summary(),
Some("上游服务拒绝认证(HTTP 401)")
);
assert!(unauthorized.is_retryable());
}
/// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。
#[test]
fn terminal_native_kinds_are_not_fed_back_to_the_model() {
@@ -6288,7 +6288,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。
- **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。
- **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex:<clientTurnId>:assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。
- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex:<clientTurnId>:assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:<uuid>`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。sidecar 按 `mcp__<server>` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。
- **根因 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:<clientTurnId>:assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:<uuid>`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。sidecar 按 `mcp__<server>` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。
- **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 `<sensitive diagnostic details redacted>`,离线只剩"说不出原因"的 HostDropped。
- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs <expr>`):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`<!doctype html>` / `<html lang="zh-CN">`)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,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`。