统一 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
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:
@@ -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`。
|
||||
|
||||
Reference in New Issue
Block a user