保留运行时与侧车错误详情
展示 JSON-RPC、Codex CLI stderr 和 IPC 连接失败的可行动正文 未知 typed 失败继续精确脱敏并补回归测试
This commit is contained in:
@@ -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:')) {
|
||||
|
||||
+16
-4
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user