保留运行时与侧车错误详情

展示 JSON-RPC、Codex CLI stderr 和 IPC 连接失败的可行动正文

未知 typed 失败继续精确脱敏并补回归测试
This commit is contained in:
kdletters
2026-10-04 23:57:13 +08:00
parent 3b246fabd1
commit 310947ae42
6 changed files with 161 additions and 40 deletions
@@ -407,17 +407,30 @@ fn game_creator_codex_app_server_connection_error(
};
platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP 413"))
}
Some(status_code) => platform_llm::LlmError::Upstream {
status_code,
message: format!(
"Codex app-server 连接上游失败:{}",
game_creator_codex_app_server_error_display_detail(error)
),
},
None => platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 连接失败".to_string(),
},
Some(status_code) => {
let detail = game_creator_codex_app_server_error_display_detail(error);
let detail = if detail.is_empty() {
game_creator_codex_app_server_json_rpc_error_detail(error)
} else {
detail
};
platform_llm::LlmError::Upstream {
status_code,
message: format!("Codex app-server 连接上游失败({field}):{detail}"),
}
}
None => {
let detail = game_creator_codex_app_server_error_display_detail(error);
let detail = if detail.is_empty() {
game_creator_codex_app_server_json_rpc_error_detail(info)
} else {
detail
};
platform_llm::LlmError::Connectivity {
attempts: 1,
message: format!("Codex app-server 连接失败({field}):{detail}"),
}
}
}
}
@@ -472,6 +485,41 @@ fn game_creator_codex_app_server_error_display_detail(error: &serde_json::Value)
.join(";")
}
/// JSON-RPC 请求失败时不要只取可选的 `message`:有些上游只返回 `code` / `data`,
/// 丢掉整个 error 对象会把真实协议错误再次压成“未知错误”。这里保留结构化字段,
/// 最后仍由回合错误投影按项目根目录做一次精确脱敏。
fn game_creator_codex_app_server_json_rpc_error_detail(error: &serde_json::Value) -> String {
let detail = error
.as_object()
.map(|object| {
["code", "message", "additionalDetails", "data"]
.into_iter()
.filter_map(|field| object.get(field).map(|value| (field, value)))
.filter_map(|(field, value)| {
let value = match value.as_str() {
Some(value) => value.to_string(),
None if !value.is_null() => serde_json::to_string(value).ok()?,
_ => return None,
};
let value = value.trim().to_string();
(!value.is_empty()).then(|| format!("{field}={value}"))
})
.collect::<Vec<_>>()
.join(";")
})
.unwrap_or_default();
let detail = if detail.is_empty() {
serde_json::to_string(error).unwrap_or_else(|_| "无法序列化 JSON-RPC error".to_string())
} else {
detail
};
crate::agent::redact_agent_runtime_error(
std::path::Path::new("__agc_no_project_root__"),
&detail,
480,
)
}
fn game_creator_codex_app_server_error_detail_indicates_stream_requirement(
error: &serde_json::Value,
) -> bool {
@@ -4742,10 +4790,15 @@ async fn read_game_creator_codex_app_server_stdout(
}
let message = match serde_json::from_slice::<serde_json::Value>(&buffer) {
Ok(message) => message,
Err(_) => {
Err(error) => {
let raw = crate::agent::redact_agent_runtime_error(
std::path::Path::new("__agc_no_project_root__"),
&String::from_utf8_lossy(&buffer),
480,
);
fail_game_creator_codex_app_server_connection(
&inner,
"Codex app-server 返回无效 JSON-RPC".to_string(),
format!("Codex app-server 返回无效 JSON-RPC:{error};原文={raw}"),
)
.await;
return;
@@ -4899,10 +4952,7 @@ async fn read_game_creator_codex_app_server_stdout(
let result = if let Some(error) = message.get("error") {
Err(format!(
"Codex app-server JSON-RPC 失败:{}",
error
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("未知错误")
game_creator_codex_app_server_json_rpc_error_detail(error)
))
} else {
Ok(message
@@ -5608,6 +5658,22 @@ pub(crate) fn build_direct_codex_history_prompt(
#[cfg(test)]
mod tests {
#[test]
fn json_rpc_error_keeps_structured_detail_and_redacts_secret() {
let detail =
super::game_creator_codex_app_server_json_rpc_error_detail(&serde_json::json!({
"code": -32001,
"data": {
"reason": "out of memory (ENOMEM)",
"api_key": "provider-secret",
},
}));
assert!(detail.contains("code=-32001"), "{detail}");
assert!(detail.contains("out of memory (ENOMEM)"), "{detail}");
assert!(detail.contains("api_key"), "{detail}");
assert!(!detail.contains("provider-secret"), "{detail}");
}
use super::*;
#[test]
@@ -283,6 +283,7 @@ struct CodexCliStderrSummary {
byte_len: usize,
sha256: String,
classification: &'static str,
detail: Option<String>,
}
pub(crate) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
@@ -487,6 +488,7 @@ where
{
let mut byte_len = 0_usize;
let mut sha256 = Sha256::new();
let mut preview = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let count = reader
@@ -498,7 +500,20 @@ where
}
byte_len = byte_len.saturating_add(count);
sha256.update(&buffer[..count]);
let remaining = max_bytes.saturating_sub(preview.len());
preview.extend_from_slice(&buffer[..count.min(remaining)]);
}
let detail = if preview.is_empty() {
None
} else {
let text = String::from_utf8_lossy(&preview);
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
text.trim(),
480,
);
(!detail.trim().is_empty()).then_some(detail)
};
Ok(CodexCliStderrSummary {
byte_len,
sha256: format!("{:x}", sha256.finalize()),
@@ -509,6 +524,7 @@ where
} else {
"nonempty"
},
detail,
})
}
@@ -796,23 +812,33 @@ async fn request_game_creator_agent_codex_cli_with_executable(
let status = status.expect("non-timeout Codex CLI wait has exit status");
if stderr.classification == "oversized" {
return Err(platform_llm::LlmError::Transport(format!(
"Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}",
"Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}{}",
GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES,
stderr.classification,
stderr.byte_len,
stderr.sha256
stderr.sha256,
stderr
.detail
.as_deref()
.map(|detail| format!(";stderrDetail={detail}"))
.unwrap_or_default()
)));
}
if !status.success() {
return Err(platform_llm::LlmError::Transport(format!(
"Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置",
"Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={}{};请检查 Codex CLI 登录状态、网络和本机配置",
status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "none".to_string()),
stderr.classification,
stderr.byte_len,
stderr.sha256
stderr.sha256,
stderr
.detail
.as_deref()
.map(|detail| format!(";stderrDetail={detail}"))
.unwrap_or_default()
)));
}
if let Some(error) = stdin_error {
@@ -1126,21 +1152,24 @@ mod tests {
}
#[tokio::test]
async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() {
async fn codex_cli_mode_bounds_unterminated_output_and_keeps_sanitized_stderr() {
let output = vec![b'x'; 33];
let error = read_game_creator_codex_cli_output(output.as_slice(), 32)
.await
.expect_err("unterminated output over the cap must fail");
assert!(error.contains("超过 32 字节上限"));
let stderr = b"private-auth-detail-without-newline";
let stderr = b"provider connection refused";
let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256)
.await
.expect("summarize stderr");
assert_eq!(summary.classification, "nonempty");
assert_eq!(summary.byte_len, stderr.len());
assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr)));
assert!(!summary.sha256.contains("private-auth-detail"));
assert_eq!(
summary.detail.as_deref(),
Some("provider connection refused")
);
}
#[test]
@@ -1246,14 +1275,14 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens
#[cfg(unix)]
#[tokio::test]
async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() {
async fn codex_cli_mode_preserves_sanitized_process_stderr_on_failure() {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let executable = temp.path().join("fake-codex-failure");
std::fs::write(
&executable,
"#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n",
"#!/bin/sh\nprintf '%s\\n' 'Authorization: Bearer secret-auth-detail; out of memory (ENOMEM)' >&2\nexit 43\n",
)
.expect("write fake codex");
let mut permissions = std::fs::metadata(&executable)
@@ -1271,10 +1300,12 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens
.to_string();
assert!(error.contains("code=43"));
assert!(error.contains("stderrClass=nonempty"));
assert!(error.contains("stderrBytes=19"));
assert!(error.contains("stderrBytes="));
assert!(error.contains("stderrSha256="));
assert!(error.contains("out of memory (ENOMEM)"));
assert!(error.contains("[redacted-secret]"));
assert!(error.contains("登录状态"));
assert!(!error.contains("secret-auth-detail"));
assert!(!error.contains("Bearer secret-auth-detail"));
}
#[cfg(unix)]
@@ -370,7 +370,7 @@ export function projectRuntimeVisibleError(
other: '智能创作执行失败,请查看运行详情后重试',
}[codexAppServerKind];
if (detail) {
return `${subject} ${detail}`;
return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`;
}
}
const directCodexAppServerKind = visibleMessage.match(
@@ -389,7 +389,7 @@ export function projectRuntimeVisibleError(
other: '未完成本次执行,请查看项目文件是否已修改后再重试',
}[directCodexAppServerKind];
if (detail) {
return `${subject} ${detail}`;
return `${subject} ${detail}${runtimeFailureDetail(message) ?? ''}`;
}
}
if (visibleMessage.includes('codex-app-server-terminal-unknown:')) {
@@ -149,7 +149,7 @@ function directModelCallText(
): string {
// 载荷跨 IPC 没有运行时校验:`kind` 缺失时按"没有分类"兜底,别让整条事件订阅在这一步抛错。
if (!kind) {
return '智能服务执行失败,请稍后重试';
return withVisibleFailureDetail('智能服务执行失败,请稍后重试', detail);
}
switch (kind.type) {
case 'responseTimedOut':
@@ -326,11 +326,23 @@ export function directTurnFailureNoticeText(failure: TurnFailure): string {
failure.detail,
);
case 'hostDropped':
return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。';
return withVisibleFailureDetail(
'陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。',
typeof (failure as { detail?: unknown }).detail === 'string'
? (failure as unknown as { detail: string }).detail
: null,
);
default: {
expectNever(failure);
// 结构化映射外(未来宿主的新变体):给一句通用话,绝不能把原文回落给用户。
return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`;
// 跨 IPC 的未来变体仍可能携带 detail;精确脱敏后保留它,避免新增错误被通用句吞掉。
const detail =
typeof (failure as { detail?: unknown }).detail === 'string'
? (failure as unknown as { detail: string }).detail
: null;
return withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`,
detail,
);
}
}
}
@@ -475,7 +475,9 @@ describe('DirectProject 聊天 reducer', () => {
const notice = failed.history.at(-1);
expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure');
expect(notice?.role).toBe('assistant');
expect(notice?.text).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试');
expect(notice?.text).toBe(
'陶泥儿智能创作 服务连接已断开,请稍后重试:DirectProject 收尾历史失败:未确认历史完整落盘',
);
// 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。
expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900);
expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000);
@@ -502,7 +504,7 @@ describe('DirectProject 聊天 reducer', () => {
}),
]);
expect(failed.history.at(-1)?.text).toBe(
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试:codex-app-server-error:context-window-exceeded',
);
});
@@ -585,9 +587,9 @@ describe('DirectProject 聊天 reducer', () => {
expect(failed.history).toHaveLength(0);
});
it('未知失败变体不抛错,按通用文案收口', () => {
// 跨 IPC 的载荷没有运行时校验:未来宿主的新变体也到得了 reducer。这里只要求
// "不抛错 + 不把宿主原文当文案",终态照样收口——抛错会连带打断这条订阅之后的所有事件。
it('未知失败变体不抛错,并保留精确脱敏 detail 收口', () => {
// 跨 IPC 的载荷没有运行时校验:未来宿主的新变体也到得了 reducer。终态照样收口,
// 同时把它携带的错误正文交给统一脱敏出口——抛错会连带打断这条订阅之后的所有事件。
const malformed = {
type: 'turn.completed',
status: 'failed',
@@ -604,7 +606,7 @@ describe('DirectProject 聊天 reducer', () => {
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(2_000);
expect(failed.history.at(-1)?.text).toBe(
'陶泥儿智能创作 执行失败,请稍后重试',
'陶泥儿智能创作 执行失败,请稍后重试:宿主原文不得上屏',
);
});
@@ -93,4 +93,14 @@ describe('DirectProject 上游失败文案', () => {
expect(text).toContain('spawn ENOENT');
expect(text).toContain('Node runtime not found');
});
it('缺少分类时也保留 IPC 传来的错误正文', () => {
const text = directTurnFailureNoticeText({
type: 'modelCallFailed',
kind: null as never,
detail: 'IPC invoke failed: channel closed (EPIPE)',
});
expect(text).toContain('channel closed (EPIPE)');
});
});