收敛 CLI 协议错误具体文本

保留 Claude sidecar 的解析、失败终态和退出状态

保留 Codex CLI 的失败事件与结构化输出 detail

补回 cc 超时和空回执的统一错误正文

补充脱敏协议错误回归测试与错误处理文档
This commit is contained in:
kdletters
2026-10-05 16:14:08 +08:00
parent ad23dbf554
commit a56859ff98
6 changed files with 267 additions and 58 deletions
@@ -45,7 +45,7 @@ pub mod tool;
pub(crate) use art_manifest::*;
use claude_code_cli::*;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_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,
};
@@ -255,6 +255,7 @@ async fn run_sidecar_turn(
let mut terminal = None;
let mut idle_rounds = 0_u64;
let mut silent_ms = 0_u64;
let mut last_event_detail = None;
loop {
let line =
match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line())
@@ -300,6 +301,13 @@ async fn run_sidecar_turn(
"Claude Agent SDK sidecar 输出不是有效 JSON:{error};原文={detail}"
))
})?;
last_event_detail = serde_json::to_string(&value).ok().map(|detail| {
crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
480,
)
});
match value.get("type").and_then(serde_json::Value::as_str) {
Some("event") => {
if let Some(event) = value.get("event") {
@@ -315,12 +323,15 @@ async fn run_sidecar_turn(
}
Some("error") => {
kill_claude_code_process_tree(pid);
let detail = claude_result_error_detail(&value).or_else(|| {
last_event_detail
.clone()
.filter(|detail| !detail.trim().is_empty())
});
return Err(SidecarTurnFailure::message(
value
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Claude Agent SDK sidecar 执行失败")
.to_string(),
detail
.map(|detail| format!("Claude Agent SDK sidecar 执行失败:{detail}"))
.unwrap_or_else(|| "Claude Agent SDK sidecar 执行失败".to_string()),
));
}
_ => {}
@@ -329,13 +340,23 @@ async fn run_sidecar_turn(
let status = child.wait().await.map_err(|error| {
SidecarTurnFailure::message(format!("等待 Claude Agent SDK sidecar 结束失败:{error}"))
})?;
let exit_status = status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "signal".to_string());
let result = terminal.ok_or_else(|| {
SidecarTurnFailure::message("Claude Agent SDK sidecar 缺少终态".to_string())
SidecarTurnFailure::message(format!(
"Claude Agent SDK sidecar 缺少终态;exitStatus={exit_status};已收到 {} 个事件;最后事件={}",
event_count,
last_event_detail
.filter(|detail| !detail.trim().is_empty())
.unwrap_or_else(|| "<无有效事件>".to_string())
))
})?;
if !status.success() {
return Err(SidecarTurnFailure::message(
"Claude Agent SDK sidecar 非零退出".to_string(),
));
return Err(SidecarTurnFailure::message(format!(
"Claude Agent SDK sidecar 非零退出;exitStatus={exit_status}"
)));
}
Ok(SidecarTurnResult { result })
};
@@ -658,22 +679,37 @@ fn configure_claude_code_environment(
}
fn claude_result_error_detail(value: &serde_json::Value) -> Option<String> {
fn value_detail(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(detail) => {
let detail = detail.trim();
(!detail.is_empty()).then(|| detail.chars().take(512).collect())
}
serde_json::Value::Object(object) => {
let detail = ["message", "detail", "error", "type", "code"]
.into_iter()
.filter_map(|field| object.get(field).and_then(value_detail))
.collect::<Vec<_>>()
.join(";");
(!detail.is_empty()).then_some(detail)
}
_ => None,
}
}
let candidates = [
value.get("result").and_then(serde_json::Value::as_str),
value.get("error").and_then(serde_json::Value::as_str),
value.get("message").and_then(serde_json::Value::as_str),
value.get("result").and_then(value_detail),
value.get("error").and_then(value_detail),
value.get("message").and_then(value_detail),
value
.get("errors")
.and_then(serde_json::Value::as_array)
.and_then(|errors| errors.first())
.and_then(serde_json::Value::as_str),
.and_then(value_detail),
];
candidates
.into_iter()
.flatten()
.map(str::trim)
.find(|detail| !detail.is_empty())
.map(|detail| detail.chars().take(512).collect())
}
fn configure_claude_code_process(command: &mut tokio::process::Command) {
@@ -806,6 +842,30 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::
platform_llm::LlmError::Transport(detail)
}
/// 把 cc 的失败投影到 Direct 回合错误;平台 `LlmError::Timeout` / `EmptyResponse` 本身没有
/// detail 字段,因此这两类必须在这里把 sidecar 的原始原因留在统一 `ModelCallFailed.detail` 中,
/// 否则 stderr、超时阶段和缺失字段会在类型转换时丢掉,前端又只能显示固定兜底句。
pub(crate) fn claude_code_failure_to_turn_error(
detail: String,
) -> super::codex_app_server::turn_error::TurnError {
use super::codex_app_server::turn_error::{ModelCallFailed, ModelCallKind, TurnError};
let llm_error = claude_code_failure_to_llm_error(detail.clone());
match llm_error {
platform_llm::LlmError::Timeout { attempts } => {
TurnError::ModelCallFailed(ModelCallFailed {
kind: ModelCallKind::ResponseTimedOut { attempts },
detail,
})
}
platform_llm::LlmError::EmptyResponse => TurnError::ModelCallFailed(ModelCallFailed {
kind: ModelCallKind::EmptyResponse,
detail,
}),
other => TurnError::from_model_call(&other),
}
}
fn claude_code_failure_status_code(detail: &str) -> Option<u16> {
let normalized = detail.to_ascii_lowercase();
[
@@ -835,8 +895,15 @@ fn parse_claude_code_result(
stdout: &[u8],
request: &LlmRunRequest,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|_| {
platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string())
let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|error| {
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&String::from_utf8_lossy(stdout),
480,
);
platform_llm::LlmError::Deserialize(format!(
"Claude Code JSON 输出无效:{error};原文={detail}"
))
})?;
if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) {
let detail = claude_result_error_detail(&value)
@@ -863,20 +930,33 @@ fn parse_claude_code_result(
}
(result, Vec::new())
} else {
let envelope = structured
.or_else(|| serde_json::from_str(&result).ok())
.ok_or_else(|| {
platform_llm::LlmError::Deserialize(
"Claude Code structured output 缺失".to_string(),
)
})?;
let envelope = match structured {
Some(value) => value,
None => serde_json::from_str(&result).map_err(|error| {
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&result,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Claude Code structured output JSON 无效:{error};原文={detail}"
))
})?,
};
let calls = envelope
.get("toolCalls")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
platform_llm::LlmError::Deserialize(
"Claude Code structured output 缺少 toolCalls".to_string(),
)
let detail =
serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".into());
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Claude Code structured output 缺少 toolCalls;原文={detail}"
))
})?;
let calls = calls
.iter()
@@ -886,17 +966,20 @@ fn parse_claude_code_result(
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
platform_llm::LlmError::Deserialize(
"Claude Code tool call 缺少 name".to_string(),
)
platform_llm::LlmError::Deserialize(format!(
"Claude Code tool call 第 {} 项缺少 name;原文={}",
index.saturating_add(1),
serde_json::to_string(call).unwrap_or_else(|_| "<不可序列化>".into())
))
})?;
let arguments = call
.get("arguments")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
platform_llm::LlmError::Deserialize(
"Claude Code tool call 缺少 arguments".to_string(),
)
platform_llm::LlmError::Deserialize(format!(
"Claude Code tool call 第 {} 项缺少 arguments;name={name}",
index.saturating_add(1)
))
})?;
if !request.function_tools.iter().any(|tool| tool.name == name) {
return Err(platform_llm::LlmError::Deserialize(
@@ -1733,6 +1816,63 @@ mod tests {
assert!(error.to_string().contains("Authentication failed"));
}
#[test]
fn preserves_claude_invalid_json_and_object_error_details() {
let invalid = parse_claude_code_result(
br#"{"is_error":false,"result":"unterminated""#,
&request(false),
)
.expect_err("invalid JSON must be rejected");
let invalid = invalid.to_string();
assert!(invalid.contains("Claude Code JSON 输出无效"), "{invalid}");
assert!(invalid.contains("line"), "{invalid}");
assert!(invalid.contains("unterminated"), "{invalid}");
let object_error = parse_claude_code_result(
br#"{"is_error":true,"error":{"type":"invalid_request_error","message":"HTTP 422: bad schema"}}"#,
&request(false),
)
.expect_err("object error must be rejected");
assert!(
object_error.to_string().contains("HTTP 422: bad schema"),
"{object_error}"
);
}
#[test]
fn cc_failure_projection_preserves_timeout_and_empty_response_detail() {
let timeout = super::claude_code_failure_to_turn_error(
"Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件;stderr=OOM".into(),
);
match timeout {
super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => {
assert!(matches!(
payload.kind,
super::codex_app_server::turn_error::ModelCallKind::ResponseTimedOut {
attempts: 1
}
));
assert!(payload.detail.contains("180000"));
assert!(payload.detail.contains("OOM"));
}
other => panic!("expected timed out model failure, got {other:?}"),
}
let empty = super::claude_code_failure_to_turn_error(
"Claude Code 缺少最终回复;sidecar result 缺少 result 字段".into(),
);
match empty {
super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => {
assert!(matches!(
payload.kind,
super::codex_app_server::turn_error::ModelCallKind::EmptyResponse
));
assert!(payload.detail.contains("缺少 result 字段"));
}
other => panic!("expected empty response model failure, got {other:?}"),
}
}
#[test]
fn claude_failure_reuses_codex_error_categories() {
assert!(matches!(
@@ -5428,9 +5428,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
observer,
)
.await
.map_err(|detail| {
TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail))
});
.map_err(crate::agent::claude_code_failure_to_turn_error);
}
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
TurnError::EnvironmentNotReady(EnvironmentNotReady {
@@ -532,13 +532,14 @@ fn parse_game_creator_codex_cli_response(
stdout: &[u8],
request: &LlmRunRequest,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let stdout = std::str::from_utf8(stdout).map_err(|_| {
platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string())
let stdout = std::str::from_utf8(stdout).map_err(|error| {
platform_llm::LlmError::Deserialize(format!("Codex CLI Agent JSONL 不是 UTF-8:{error}"))
})?;
let mut response_id = None;
let mut final_message = None;
let mut usage = None;
let mut completed = false;
let mut last_event_detail = None;
for (index, line) in stdout.lines().enumerate() {
if line.trim().is_empty() {
continue;
@@ -554,6 +555,11 @@ fn parse_game_creator_codex_cli_response(
index.saturating_add(1)
))
})?;
last_event_detail = Some(crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
line,
480,
));
match event.get("type").and_then(serde_json::Value::as_str) {
Some("thread.started") => {
response_id = event
@@ -592,11 +598,14 @@ fn parse_game_creator_codex_cli_response(
}
}
Some("turn.failed") => {
let detail = event
let mut detail = event
.get("error")
.or_else(|| event.get("message"))
.map(serde_json::Value::to_string)
.unwrap_or_default();
if detail.trim().is_empty() {
detail = serde_json::to_string(&event).unwrap_or_default();
}
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
detail.trim_matches('"'),
@@ -612,9 +621,12 @@ fn parse_game_creator_codex_cli_response(
}
}
if !completed {
return Err(platform_llm::LlmError::Deserialize(
"Codex CLI Agent 缺少 turn.completed 终态".to_string(),
));
return Err(platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent 缺少 turn.completed 终态;最后事件={}",
last_event_detail
.filter(|detail| !detail.trim().is_empty())
.unwrap_or_else(|| "<无有效事件>".to_string())
)));
}
let text = final_message
.filter(|text| !text.trim().is_empty())
@@ -622,18 +634,30 @@ fn parse_game_creator_codex_cli_response(
let tool_calls = if request.function_tools.is_empty() {
Vec::new()
} else {
let envelope = serde_json::from_str::<serde_json::Value>(&text).map_err(|_| {
platform_llm::LlmError::Deserialize(
"Codex CLI Agent structured output 不是严格 JSON".to_string(),
)
let envelope = serde_json::from_str::<serde_json::Value>(&text).map_err(|error| {
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&text,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent structured output JSON 无效:{error};原文={detail}"
))
})?;
let calls = envelope
.get("toolCalls")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
platform_llm::LlmError::Deserialize(
"Codex CLI Agent structured output 缺少 toolCalls".to_string(),
)
let detail =
serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".to_string());
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent structured output 缺少 toolCalls;原文={detail}"
))
})?;
calls
.iter()
@@ -643,17 +667,26 @@ fn parse_game_creator_codex_cli_response(
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
platform_llm::LlmError::Deserialize(
"Codex CLI Agent tool call 缺少 name".to_string(),
)
let detail = serde_json::to_string(call)
.unwrap_or_else(|_| "<不可序列化>".to_string());
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent tool call 第 {} 项缺少 name;原文={detail}",
index.saturating_add(1)
))
})?;
let arguments = call
.get("arguments")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
platform_llm::LlmError::Deserialize(
"Codex CLI Agent tool call 缺少 arguments".to_string(),
)
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent tool call 第 {} 项缺少 arguments;name={name}",
index.saturating_add(1)
))
})?;
if !request.function_tools.iter().any(|tool| tool.name == name) {
return Err(platform_llm::LlmError::Deserialize(
@@ -1248,6 +1281,15 @@ mod tests {
assert!(failed_text.contains("HTTP 502 upstream overloaded"));
assert!(!failed_text.contains("fixture-secret"));
let failed_without_message = parse_game_creator_codex_cli_response(
b"{\"type\":\"turn.failed\",\"code\":\"EPIPE\",\"status\":\"closed\"}\n",
&request,
)
.expect_err("failed terminal without message must retain event detail");
let failed_without_message = failed_without_message.to_string();
assert!(failed_without_message.contains("EPIPE"));
assert!(failed_without_message.contains("closed"));
let invalid = parse_game_creator_codex_cli_response(
b"{not-json Authorization: Bearer fixture-secret}\n",
&request,
@@ -1259,6 +1301,35 @@ mod tests {
assert!(!invalid_text.contains("fixture-secret"));
}
#[test]
fn codex_cli_mode_preserves_missing_terminal_and_structured_output_details() {
let request = tool_request();
let missing_terminal = parse_game_creator_codex_cli_response(
b"{\"type\":\"item.completed\",\"item\":{\"type\":\"error\",\"message\":\"IPC EPIPE\"}}\n",
&request,
)
.expect_err("missing terminal must retain the last protocol event");
let missing_terminal = missing_terminal.to_string();
assert!(missing_terminal.contains("缺少 turn.completed 终态"));
assert!(missing_terminal.contains("IPC EPIPE"));
let malformed_event = serde_json::json!({
"type": "item.completed",
"item": { "type": "agent_message", "text": r#"{"toolCalls":["# }
});
let malformed_stdout = format!(
"{}\n{{\"type\":\"turn.completed\"}}\n",
serde_json::to_string(&malformed_event).expect("serialize malformed event")
);
let malformed_structured =
parse_game_creator_codex_cli_response(malformed_stdout.as_bytes(), &request)
.expect_err("malformed structured output must retain parse detail");
let malformed_structured = malformed_structured.to_string();
assert!(malformed_structured.contains("structured output JSON 无效"));
assert!(malformed_structured.contains("line"));
assert!(malformed_structured.contains("toolCalls"));
}
#[cfg(unix)]
#[tokio::test]
async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() {
@@ -107,7 +107,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
- 脱敏只替换敏感值、完整 URL、绝对路径和私钥内容;字段名(例如 `Authorization`、`token`、`api_key`)保留,便于判断错误类别。原始凭据值不进入用户文本、应用日志或项目诊断。
- 上游 app-server 映射保留 `message` / `additionalDetails` 的脱敏摘要;因此 401/403/408/429/413/5xx 等状态不会因为分类为 transport 或 native 而丢失正文。
- Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。
- Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败和失败终态保留解析错误、失败事件正文与有界安全片段,不再把无效 JSON、`turn.failed` 或缺失终态统一压成单一类别。
- Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。
- HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。
- `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。
- DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。
@@ -17,7 +17,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md`
8. 项目资源编辑/上传/预览/恢复队列/生成结果回读的 UI 错误统一使用 `normalizeDiagnosticText`,不再直出 `Error.message`。
9. 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的非结构化错误统一使用 `visibleClientErrorMessage`。
10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。
11. Codex CLI / Claude sidecar 的解析失败与失败终态保留有界脱敏正文,避免协议错误只剩类别码。
11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。
## 实现顺序