收敛 AGC 与 CC 错误详情处理

统一上游、IPC、侧车和宿主失败的脱敏正文展示

修正 CC 回合预算和非支付 409 分类

保留 stderr 与日志中的可行动错误并补定向回归
This commit is contained in:
kdletters
2026-10-04 23:38:06 +08:00
parent 441b3fcb51
commit 3b246fabd1
15 changed files with 629 additions and 191 deletions
@@ -354,11 +354,9 @@ async fn run_sidecar_turn(
let result = match outcome {
Ok(result) => result,
Err(message) => {
let Some(reason) = timeout_reason else {
return Err(message);
};
// 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着,
// stderr 等不到 EOF,这一轮就永远出不了终态。
// 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有
// 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉
// sidecar 给出的真正原因,最终只剩一条 transport 通用句。
kill_claude_code_process_tree(pid);
let stderr_detail =
match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await {
@@ -367,8 +365,23 @@ async fn run_sidecar_turn(
}
.map(|value| value.trim().chars().take(512).collect::<String>())
.filter(|value| !value.is_empty())
// 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。
.filter(|value| !value.contains("EPIPE"));
// 断开 stdout 之后 sidecar 自己会抛 EPIPE;过滤该噪声行,但保留同一份
// stderr 中其它真正的 provider / Node 错误。
.map(|value| {
value
.lines()
.filter(|line| !line.contains("EPIPE"))
.collect::<Vec<_>>()
.join(" ")
})
.filter(|value| !value.trim().is_empty());
let reason = timeout_reason.unwrap_or_else(|| {
if message.trim().is_empty() {
"Claude Agent SDK sidecar 执行失败".to_string()
} else {
message
}
});
let detail = stderr_detail
.map(|value| format!(":{value}"))
.unwrap_or_default();
@@ -760,6 +773,15 @@ fn parse_usage(value: &serde_json::Value) -> Option<platform_llm::LlmTokenUsage>
/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` /
/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。
pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError {
let normalized = detail.to_ascii_lowercase();
if normalized.contains("maximum number of turns")
|| normalized.contains("max turns")
|| normalized.contains("最大回合数")
{
return platform_llm::LlmError::InvalidRequest(format!(
"codex-app-server-error:session-budget-exceeded detail={detail}"
));
}
if let Some(status_code) = claude_code_failure_status_code(&detail) {
return platform_llm::LlmError::Upstream {
status_code,
@@ -780,8 +802,18 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::
}
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 normalized = detail.to_ascii_lowercase();
[
"request rejected (",
"http ",
"status ",
"status=",
"status_code=",
"statuscode=",
]
.iter()
.find_map(|marker| {
let tail = normalized.split_once(marker)?.1;
let digits = tail
.chars()
.take_while(|character| character.is_ascii_digit())
@@ -1600,7 +1632,12 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
match &parsed {
Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())),
Err(error) => {
eprintln!("[agc-cc-direct] parse failed: {error}");
// 统一进入应用日志的精确脱敏出口;不再把原始 parse detail 直接写 stderr,避免
// 凭据/路径随 cc 收尾错误绕过 Runtime 诊断合同。
app_log!(
"agent.direct_codex.claude_parse_failed detail={}",
crate::agent::redact_agent_runtime_error(root, &error, 600)
);
direct_turn_trace("claude-parse-error");
}
}
@@ -1712,6 +1749,31 @@ mod tests {
claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()),
platform_llm::LlmError::EmptyResponse
));
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Code 返回失败终态:HTTP 409 session already active".into()
),
platform_llm::LlmError::Upstream {
status_code: 409,
..
}
));
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Agent SDK 返回错误:status_code=401 invalid token".into()
),
platform_llm::LlmError::Upstream {
status_code: 401,
..
}
));
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Code 返回失败终态:Reached maximum number of turns (8)".into()
),
platform_llm::LlmError::InvalidRequest(message)
if message.contains("codex-app-server-error:session-budget-exceeded")
));
}
#[test]
@@ -344,6 +344,22 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail(
if !keys.is_empty() {
fields.push(format!("fields={}", keys.join(",")));
}
let detail = ["message", "additionalDetails"]
.into_iter()
.filter_map(|field| object.get(field).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(";");
if !detail.is_empty() {
// 先在 app-server 投影边界做一次无根目录脱敏;终态载荷还会按项目根目录再脱敏。
let safe_detail = crate::agent::redact_agent_runtime_error(
std::path::Path::new("__agc_no_project_root__"),
&detail,
480,
);
fields.push(format!("detail={safe_detail}"));
}
}
let suffix = if fields.is_empty() {
String::new()
@@ -369,13 +385,34 @@ fn game_creator_codex_app_server_error_http_status(
fn game_creator_codex_app_server_connection_error(
info: &serde_json::Value,
field: &str,
error: &serde_json::Value,
) -> platform_llm::LlmError {
match game_creator_codex_app_server_error_http_status(info, field) {
Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"),
Some(413) => game_creator_codex_app_server_error_kind("request-too-large"),
Some(status_code @ (401 | 403)) => {
let platform_llm::LlmError::InvalidRequest(message) =
game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error)
else {
unreachable!("native unauthorized projection must remain InvalidRequest");
};
platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP {status_code}"))
}
Some(413) => {
let platform_llm::LlmError::InvalidRequest(message) =
game_creator_codex_app_server_error_kind_with_machine_detail(
"request-too-large",
error,
)
else {
unreachable!("native request-too-large projection must remain InvalidRequest");
};
platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP 413"))
}
Some(status_code) => platform_llm::LlmError::Upstream {
status_code,
message: "Codex app-server 连接上游失败".to_string(),
message: format!(
"Codex app-server 连接上游失败:{}",
game_creator_codex_app_server_error_display_detail(error)
),
},
None => platform_llm::LlmError::Connectivity {
attempts: 1,
@@ -422,6 +459,19 @@ fn game_creator_codex_app_server_error_detail(error: &serde_json::Value) -> Stri
.to_ascii_lowercase()
}
fn game_creator_codex_app_server_error_display_detail(error: &serde_json::Value) -> String {
let Some(error) = error.as_object() else {
return String::new();
};
["message", "additionalDetails", "code"]
.into_iter()
.filter_map(|field| error.get(field).and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join(";")
}
fn game_creator_codex_app_server_error_detail_indicates_stream_requirement(
error: &serde_json::Value,
) -> bool {
@@ -494,19 +544,28 @@ fn game_creator_codex_app_server_failed_turn_error(
};
}
if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) {
return game_creator_codex_app_server_error_kind("request-too-large");
return game_creator_codex_app_server_error_kind_with_machine_detail(
"request-too-large",
error,
);
}
if game_creator_codex_app_server_error_detail_indicates_stream_requirement(error) {
return game_creator_codex_app_server_error_kind("stream-required");
return game_creator_codex_app_server_error_kind_with_machine_detail(
"stream-required",
error,
);
}
if game_creator_codex_app_server_error_detail_indicates_timeout(error) {
return platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 上游请求超时".to_string(),
message: format!(
"Codex app-server 上游请求超时:{}",
game_creator_codex_app_server_error_display_detail(error)
),
};
}
if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) {
return game_creator_codex_app_server_error_kind("unauthorized");
return game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error);
}
let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else {
return game_creator_codex_app_server_error_kind_with_machine_detail("other", error);
@@ -514,25 +573,44 @@ fn game_creator_codex_app_server_failed_turn_error(
if let Some(kind) = info.as_str() {
return match kind {
"contextWindowExceeded" => {
game_creator_codex_app_server_error_kind("context-window-exceeded")
game_creator_codex_app_server_error_kind_with_machine_detail(
"context-window-exceeded",
error,
)
}
"sessionBudgetExceeded" => {
game_creator_codex_app_server_error_kind("session-budget-exceeded")
}
"usageLimitExceeded" => {
game_creator_codex_app_server_error_kind("usage-limit-exceeded")
game_creator_codex_app_server_error_kind_with_machine_detail(
"session-budget-exceeded",
error,
)
}
"usageLimitExceeded" => game_creator_codex_app_server_error_kind_with_machine_detail(
"usage-limit-exceeded",
error,
),
"serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream {
status_code: 503,
message: "Codex app-server 上游服务暂时不可用".to_string(),
message: format!(
"Codex app-server 上游服务暂时不可用:{}",
game_creator_codex_app_server_error_display_detail(error)
),
},
"cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"),
"unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"),
"badRequest" => game_creator_codex_app_server_error_kind("bad-request"),
"threadRollbackFailed" => {
game_creator_codex_app_server_error_kind("thread-rollback-failed")
"cyberPolicy" => {
game_creator_codex_app_server_error_kind_with_machine_detail("cyber-policy", error)
}
"unauthorized" => {
game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error)
}
"badRequest" => {
game_creator_codex_app_server_error_kind_with_machine_detail("bad-request", error)
}
"threadRollbackFailed" => game_creator_codex_app_server_error_kind_with_machine_detail(
"thread-rollback-failed",
error,
),
"sandboxError" => {
game_creator_codex_app_server_error_kind_with_machine_detail("sandbox-error", error)
}
"sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"),
"other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
_ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
};
@@ -544,7 +622,7 @@ fn game_creator_codex_app_server_failed_turn_error(
"responseTooManyFailedAttempts",
] {
if info.get(field).is_some() {
return game_creator_codex_app_server_connection_error(info, field);
return game_creator_codex_app_server_connection_error(info, field, error);
}
}
if info.get("activeTurnNotSteerable").is_some() {
@@ -6956,13 +7034,8 @@ mod tests {
}
});
let error = game_creator_codex_app_server_failed_turn_error(&failed_turn);
assert_eq!(
error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:context-window-exceeded".to_string()
)
);
let visible = error.to_string();
assert!(visible.starts_with("codex-app-server-error:context-window-exceeded"));
assert!(!visible.contains(&secret));
assert!(!visible.contains("provider.example"));
assert!(!visible.contains("victim"));
@@ -6970,38 +7043,26 @@ mod tests {
#[test]
fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() {
for (info, expected) in [
for (info, expected_prefix) in [
(
serde_json::json!("usageLimitExceeded"),
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:usage-limit-exceeded".to_string(),
),
"codex-app-server-error:usage-limit-exceeded",
),
(
serde_json::json!("unauthorized"),
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:unauthorized".to_string(),
),
"codex-app-server-error:unauthorized",
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}),
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
),
"codex-app-server-error:request-too-large",
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}),
platform_llm::LlmError::Upstream {
status_code: 429,
message: "Codex app-server 连接上游失败".to_string(),
},
"上游返回 429",
),
(
serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}),
platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 连接失败".to_string(),
},
"Codex app-server 连接失败",
),
] {
let turn = serde_json::json!({
@@ -7012,10 +7073,8 @@ mod tests {
"codexErrorInfo": info
}
});
assert_eq!(
game_creator_codex_app_server_failed_turn_error(&turn),
expected
);
let actual = game_creator_codex_app_server_failed_turn_error(&turn).to_string();
assert!(actual.contains(expected_prefix), "{actual}");
}
assert_eq!(
game_creator_codex_app_server_failed_turn_error(
@@ -7040,12 +7099,9 @@ mod tests {
"codexErrorInfo": "other"
}
}));
assert_eq!(
error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:unauthorized".to_string()
)
);
assert!(error
.to_string()
.starts_with("codex-app-server-error:unauthorized"));
}
}
@@ -7065,12 +7121,9 @@ mod tests {
"codexErrorInfo": "other"
}
}));
assert_eq!(
error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
)
);
assert!(error
.to_string()
.starts_with("codex-app-server-error:request-too-large"));
}
}
@@ -7083,12 +7136,9 @@ mod tests {
"codexErrorInfo": "other"
}
}));
assert_eq!(
stream_error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:stream-required".to_string()
)
);
assert!(stream_error
.to_string()
.starts_with("codex-app-server-error:stream-required"));
let timeout_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
@@ -7096,13 +7146,12 @@ mod tests {
"codexErrorInfo": "other"
}
}));
assert_eq!(
timeout_error,
platform_llm::LlmError::Connectivity {
attempts: 1,
message: "Codex app-server 上游请求超时".to_string()
}
);
assert!(timeout_error
.to_string()
.contains("Codex app-server 上游请求超时"));
assert!(timeout_error
.to_string()
.contains("provider request timed out"));
}
#[test]
@@ -805,7 +805,10 @@ impl TurnError {
native: native_kind(&detail),
}
}
LlmError::Upstream { status_code, .. } if *status_code == 409 => {
LlmError::Upstream {
status_code,
message,
} if *status_code == 409 && is_mud_points_upstream_message(message) => {
ModelCallKind::PaidCreditsInsufficient
}
LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed {
@@ -836,6 +839,15 @@ impl TurnError {
}
}
fn is_mud_points_upstream_message(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
message.contains("泥点余额不足")
|| message.contains("可消费泥点不足")
|| normalized.contains("insufficient_mud_points")
|| normalized.contains("insufficient-mud-points")
|| normalized.contains("mud points insufficient")
}
/// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。
///
/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。
@@ -1361,6 +1373,27 @@ mod tests {
assert!(!projected.is_model_repairable());
}
#[test]
fn non_payment_upstream_409_keeps_the_upstream_status_and_detail() {
let projected = TurnError::from_model_call(&LlmError::Upstream {
status_code: 409,
message: "Claude Code 返回冲突(HTTP 409):session already active".into(),
});
match projected {
TurnError::ModelCallFailed(payload) => {
assert_eq!(
payload.kind,
ModelCallKind::UpstreamFailed {
status_code: 409,
native: None,
}
);
assert!(payload.detail.contains("session already active"));
}
other => panic!("expected upstream 409 failure, got {other:?}"),
}
}
#[test]
fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() {
let rate_limited = TurnError::from_model_call(&LlmError::Upstream {
@@ -621,7 +621,6 @@ const ERROR_SENSITIVE_ASSIGNMENT_KEYS: &[&str] = &[
];
const ERROR_REDACTED_VALUE: &str = "[redacted-secret]";
const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]";
/// Redact an error for display in Runtime state, diagnostics, and tool
/// results. This intentionally does not call `sanitize_prompt_context`: the
@@ -952,14 +951,11 @@ fn redact_error_sensitive_assignments(line: &str) -> String {
if value_start < cursor {
break;
}
// Do not retain the sensitive field name itself. A warning may be
// serialized to the Agent/UI, and names such as `api_key` or
// `Authorization` are sensitive context even after their values have
// been replaced. Preserve surrounding quotes, separators, and
// whitespace so JSON-ish diagnostics remain readable.
// 保留敏感字段名,只替换值:Authorization/api_key/token 本身是排障分类,值才是秘密。
// 同时保留引号、分隔符和空白,使 JSON-ish 错误仍然可读。
let key_end = key_start + key_len;
output.push_str(&line[cursor..key_start]);
output.push_str(ERROR_REDACTED_KEY);
output.push_str(&line[key_start..key_end]);
output.push_str(&line[key_end..value_start]);
let (value_end, replacement) = error_assignment_value_replacement(line, value_start);
if value_end <= value_start {
@@ -405,9 +405,10 @@ mod tests {
let identity = crate::sanitize_diagnostic_message(&marked[0], None);
assert!(identity.contains("eventId=error-3-1"), "{identity}");
assert!(identity.contains("code=tool-error"), "{identity}");
assert_eq!(
crate::sanitize_diagnostic_message(&marked[1], None),
"<sensitive diagnostic details redacted>"
let marked_detail = crate::sanitize_diagnostic_message(&marked[1], None);
assert!(
marked_detail.contains("credential rotation failed"),
"{marked_detail}"
);
}
@@ -96,9 +96,8 @@ impl Drop for TurnReservation {
if finalized_by_guard {
// 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场,
// 不必只依赖 AppData 的应用日志。
// 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记,
// 整行被替换成 `<sensitive diagnostic details redacted>`,离线就只剩一个
// 说不出原因的 HostDropped。
// 保留回合定位字段;错误日志只替换敏感值,不再因字段名命中凭据标记而吞掉整行,
// 这样离线仍能看出 HostDropped 的发生位置。
let failure = TurnError::turn_failed(
FailureStage::CodeGeneration,
format!(
@@ -1712,23 +1712,11 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat
sanitized = sanitized.replace(root.as_ref(), "<appdata>");
}
}
let lowercase = sanitized.to_ascii_lowercase();
if [
"authorization",
"bearer ",
"api_key",
"apikey",
"api key",
"x-api-key",
"token=",
"token:",
"credential",
]
.iter()
.any(|marker| lowercase.contains(marker))
{
return "<sensitive diagnostic details redacted>".to_string();
}
// 错误日志是排障材料:只替换敏感值,不能因同一行出现 authorization/token/credential
// 字段就把 HTTP 状态、错误码和其它原因一起删掉。
// 这里没有项目根时使用一个不会存在的哨兵路径,避免把日志中的普通句点当成项目路径替换。
sanitized =
agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &sanitized, 2_048);
sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized));
sanitized.chars().take(2_048).collect()
}
@@ -2058,8 +2046,13 @@ mod diagnostic_log_tests {
fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() {
assert_eq!(
sanitize_diagnostic_message("Authorization: Bearer secret", None),
"<sensitive diagnostic details redacted>"
"Authorization: Bearer [redacted-secret]"
);
let upstream =
sanitize_diagnostic_message("upstream HTTP 401: invalid token; retry-after=30", None);
assert!(upstream.contains("HTTP 401"), "{upstream}");
assert!(upstream.contains("invalid token"), "{upstream}");
assert!(upstream.contains("retry-after=30"), "{upstream}");
assert_eq!(
sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None),
"failed at <absolute-path>"
@@ -253,15 +253,13 @@ function directPlatformFailureDetail(message: string) {
return null;
}
const lower = trimmed.toLowerCase();
if (
!(
lower.includes('陶泥儿美术包生成失败') ||
lower.includes('平台图片生成任务失败') ||
lower.includes('external editor') ||
lower.includes('透明美术图集') ||
lower.includes('图集切片')
)
) {
if (!(
lower.includes('陶泥儿美术包生成失败') ||
lower.includes('平台图片生成任务失败') ||
lower.includes('external editor') ||
lower.includes('透明美术图集') ||
lower.includes('图集切片')
)) {
return null;
}
if (
@@ -313,14 +311,12 @@ function directCodexFailureDetail(message: string) {
function directRuntimeFailureDetail(message: string) {
const trimmed = message.trim();
if (
!(
trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') ||
trimmed.includes('陶泥儿美术包不完整,已终止代码生成') ||
trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') ||
trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同')
)
) {
if (!(
trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') ||
trimmed.includes('陶泥儿美术包不完整,已终止代码生成') ||
trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') ||
trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同')
)) {
return null;
}
if (
@@ -442,10 +438,10 @@ export function projectRuntimeVisibleError(
normalized.includes('network') ||
normalized.includes('tls')
) {
return `${subject} 服务连接失败,请稍后重试`;
return `${subject} 服务连接失败,请稍后重试${runtimeFailureDetailSuffix(message)}`;
}
if (normalized.includes('timeout') || normalized.includes('超时')) {
return `${subject} 响应超时,请稍后重试`;
return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('unauthorized') ||
@@ -453,7 +449,7 @@ export function projectRuntimeVisibleError(
normalized.includes('401') ||
normalized.includes('鉴权')
) {
return `${subject} 鉴权失败,请检查运行时配置`;
return `${subject} 鉴权失败,请检查运行时配置${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('rate limit') ||
@@ -462,7 +458,7 @@ export function projectRuntimeVisibleError(
normalized.includes('额度') ||
normalized.includes('限流')
) {
return `${subject} 服务繁忙,请稍后重试`;
return `${subject} 服务繁忙,请稍后重试${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('needs-reconciliation') ||
@@ -470,14 +466,14 @@ export function projectRuntimeVisibleError(
normalized.includes('终态未知') ||
normalized.includes('需要人工核对')
) {
return `${subject} 运行状态需要核对,请打开运行详情后重试`;
return `${subject} 运行状态需要核对,请打开运行详情后重试${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('budget-exhausted') ||
normalized.includes('预算耗尽') ||
normalized.includes('预算已耗尽')
) {
return `${subject} 本轮预算已耗尽,请缩小任务范围后重试`;
return `${subject} 本轮预算已耗尽,请缩小任务范围后重试${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('missing expected artifact') ||
@@ -485,7 +481,7 @@ export function projectRuntimeVisibleError(
normalized.includes('缺少预期产物') ||
normalized.includes('缺少 expected artifact')
) {
return `${subject} 未生成要求的产物,请查看任务要求后重试`;
return `${subject} 未生成要求的产物,请查看任务要求后重试${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('verification') ||
@@ -494,7 +490,7 @@ export function projectRuntimeVisibleError(
normalized.includes('验证未通过') ||
normalized.includes('验证失败')
) {
return `${subject} 项目验证未通过,请查看运行详情并修复后重试`;
return `${subject} 项目验证未通过,请查看运行详情并修复后重试${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('policy') ||
@@ -503,7 +499,7 @@ export function projectRuntimeVisibleError(
normalized.includes('禁止') ||
normalized.includes('不允许')
) {
return `${subject} 被项目权限或安全策略阻止,请检查审批配置`;
return `${subject} 被项目权限或安全策略阻止,请检查审批配置${runtimeFailureDetailSuffix(message)}`;
}
if (
normalized.includes('落盘失败') ||
@@ -515,19 +511,18 @@ export function projectRuntimeVisibleError(
normalized.includes('未确认历史完整落盘') ||
normalized.includes('写入本项目对话历史失败')
) {
return `${subject} 保存运行记录失败,请检查项目目录后重试`;
return `${subject} 保存运行记录失败,请检查项目目录后重试${runtimeFailureDetailSuffix(message)}`;
}
// 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。
// 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段
// (`TransportClosed` 就是这种)。
// 宿主 `Display` 的其余事实句:优先识别宿主写死的句首短语;其余错误也保留精确脱敏正文,
// 避免 IPC/进程/操作系统错误只剩一条通用句。
if (visibleMessage.includes('执行通道已断开')) {
return `${subject} 服务连接已断开,请稍后重试`;
return `${subject} 服务连接已断开,请稍后重试${runtimeFailureDetailSuffix(message)}`;
}
if (visibleMessage.includes('等待模型回合结束达到硬上限')) {
return `${subject} 响应超时,请稍后重试`;
return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`;
}
if (visibleMessage.includes('宿主任务提前结束')) {
return `${subject} 本轮执行已中断,请重试`;
return `${subject} 本轮执行已中断,请重试${runtimeFailureDetailSuffix(message)}`;
}
const containsInternalDiagnostics =
normalized.includes('agentllm.') ||
@@ -556,7 +551,44 @@ export function projectRuntimeVisibleError(
) {
return visibleMessage;
}
return `${subject} 执行失败,请稍后重试`;
const safeDetail = runtimeFailureDetail(message);
return safeDetail
? `${subject} 执行失败${safeDetail}`
: `${subject} 执行失败,请稍后重试`;
}
/** 精确脱敏普通 Tauri/IPC/网络错误:保留错误码与正文,只替换敏感值。 */
function runtimeFailureDetail(value: string) {
const trimmed = value.trim();
if (!trimmed) return null;
const safe = trimmed
.replace(
/<redacted-url>|<redacted-secret>|<absolute-path>|\$PROJECT_ROOT/giu,
'[已隐藏信息]',
)
.replace(/\[redacted-sensitive-field\]/giu, '[已隐藏字段]')
.replace(/\[redacted-config\]/giu, '[已隐藏配置]')
.replace(/https?:\/\/[^\s"'<>]+/giu, '[已隐藏链接]')
.replace(
/(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s"'<>]+/giu,
'[已隐藏路径]',
)
.replace(
/((?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|refresh[_ -]?token|api[_ -]?key|apikey|client[_ -]?secret|private[_ -]?key|secret[_ -]?key|password|token|secret|bearer)\s*[:=]\s*)(?:Bearer\s+)?(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;,;&}\]]+)/giu,
'$1[已隐藏凭据]',
)
.replace(/(?:^|\s)kind=[a-z0-9_-]+/giu, ' ')
.replace(/(?:^|\s)(?:fingerprint|sha256)=[^\s]+/giu, ' ')
.replace(/\s+chars=\d+/giu, ' ')
.replace(/\s+/gu, ' ')
.trim()
.slice(0, 420);
if (!safe || safe === trimmed) return safe ? `:${safe}` : '';
return `:${safe}`;
}
function runtimeFailureDetailSuffix(value: string) {
return runtimeFailureDetail(value);
}
export function taskRowsFromManifest(
@@ -1,6 +1,7 @@
import { expectNever } from '../../../../app/expectNever';
import type { TauriInvoke } from '../../../../app/types';
import type { EnqueueError } from '../generated/EnqueueError';
import { visibleDirectFailureDetail } from './directTurnFailure';
/** 用户可见文案的主语:与既有运行错误横幅同一份口径。 */
const DIRECT_TURN_SUBJECT = '陶泥儿智能创作';
@@ -95,11 +96,10 @@ export function enqueueFailureNotice(failure: EnqueueError): string | null {
}
/**
* **宿主 / 环境事实**类入队失败在聊天区里的固定句;认不出形状时返回 `null`。
* **宿主 / 环境事实**类入队失败在聊天区里的分类句,并附上精确脱敏 detail;认不出形状时返回 `null`。
*
* 这类失败的 `detail` 是宿主原文(`stage=` / `code=` / 路径这类机器字段都在里面),只用于
* `.agent/runtime/errors` 与诊断,**不上屏**;所以这里只按变体给一句用户能看懂的话,不回落
* 任何字段。
* 这类失败的 `detail` 是宿主原文;前端只替换凭据值、URL 和路径,保留 `stage=` / `code=` /
* 错误码等排障字段,不因机器字段存在而丢失整条错误。
*
* `null` 只给"根本不是这份结构化载荷"的抛出(Tauri / 宿主缺陷,或未来宿主新增的变体):
* 那时说不出用户改哪一处能变好,调用方只上报 + 横幅,不写聊天。
@@ -109,9 +109,13 @@ export function enqueueSystemFailureNoticeText(
): string | null {
switch (failure.type) {
case 'environmentNotReady':
return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`;
return visibleDirectFailureDetail(failure.detail)
? `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试:${visibleDirectFailureDetail(failure.detail)}`
: `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`;
case 'hostStateUnavailable':
return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`;
return visibleDirectFailureDetail(failure.detail)
? `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试:${visibleDirectFailureDetail(failure.detail)}`
: `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`;
// 业务变体由 `enqueueFailureNotice` 认领,这里不重复给句;认不出形状时同理。
// 穷尽性由那个函数的 `default: expectNever` 保证——新增变体时那边会先编译失败。
default:
@@ -9,9 +9,8 @@
* 为什么值得单独一个文件:前两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce)
* 区分开又保持可预期;文案要按变体逐条写死,不能靠对宿主文本做子串匹配。
*
* 载荷里的 `detail` / `cause` / `diagnostic` 是**宿主原文**(走 `stage=` / `exitStatus=` /
* 路径这类机器字段),只用于诊断与分流、**不上屏**:这里只读 `type` / `stage` / `deadline` /
* `kind` 这些枚举字段。
* 载荷里的 `detail` / `cause` / `diagnostic` 是宿主原文;经过精确脱敏后,用户可见错误会保留其中
* 的 HTTP 状态、错误码和可行动原因。凭据值、URL、绝对路径和私钥内容不上屏。
*/
import { expectNever } from '../../../../app/expectNever';
@@ -27,6 +26,74 @@ const DIRECT_TURN_SUBJECT = '陶泥儿智能创作';
/** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */
const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure';
/**
* 用户可见错误正文的上限。分类前缀必须保留,正文只做有界截断,不能因为命中敏感字段
* 就把整条错误丢掉:HTTP 状态、操作系统错误码和上游的可行动说明仍然有排障价值。
*/
const DIRECT_VISIBLE_FAILURE_DETAIL_MAX_CHARS = 420;
/**
* Rust 侧已经按同一规则脱敏,但跨 IPC 的载荷不能被当成可信输入;这里做最后一道精确收口。
* 只替换凭据值、URL 和绝对路径,保留错误字段名、HTTP 状态、错误码和其它上下文。
*/
export function visibleDirectFailureDetail(
detail: string | null | undefined,
): string | null {
const text = typeof detail === 'string' ? detail.trim() : '';
if (!text) return null;
const safe = text
.replace(/<redacted-url>/gi, '[已隐藏链接]')
.replace(/<absolute-path>|\$PROJECT_ROOT/gi, '[已隐藏路径]')
.replace(/\[redacted-secret\]|<redacted-secret>/gi, '[已隐藏凭据]')
.replace(/\[redacted-sensitive-field\]/gi, '[已隐藏字段]')
.replace(/\[redacted-config\]/gi, '[已隐藏配置]')
.replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]')
.replace(/https?:\/\/[^\s"'<>]+/giu, '[已隐藏链接]')
.replace(
/(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s"'<>]+/giu,
'[已隐藏路径]',
)
.replace(
/((?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|refresh[_ -]?token|api[_ -]?key|apikey|client[_ -]?secret|private[_ -]?key|secret[_ -]?key|password|token|secret|bearer)\s*[:=]\s*)(?:Bearer\s+)?(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;,;&}\]]+)/giu,
'$1[已隐藏凭据]',
)
.replace(/\s+/gu, ' ')
.trim();
if (!safe) return null;
return safe.length > DIRECT_VISIBLE_FAILURE_DETAIL_MAX_CHARS
? `${safe.slice(0, DIRECT_VISIBLE_FAILURE_DETAIL_MAX_CHARS)}…`
: safe;
}
function withVisibleFailureDetail(
message: string,
detail: string | null | undefined,
) {
const safeDetail = visibleDirectFailureDetail(detail);
return safeDetail ? `${message}:${safeDetail}` : message;
}
function stripUpstreamPrefix(
detail: string | null | undefined,
statusCode: number,
) {
const safeDetail = visibleDirectFailureDetail(detail);
if (!safeDetail) return null;
return safeDetail
.replace(
new RegExp(`^(?:LLM\\s+)?上游返回\\s+${statusCode}\\s*[::]\\s*`, 'u'),
'',
)
.replace(
new RegExp(
`^(?:LLM\\s+)?上游\\s+HTTP\\s+${statusCode}\\s*[::]\\s*`,
'iu',
),
'',
)
.trim();
}
/** 交付阶段标签:与诊断收口文案同一份标签,只有"回合失败"才带。 */
const DIRECT_FAILURE_STAGE_LABELS: Record<FailureStage, string> = {
'art-preparation': '平台资源准备失败',
@@ -76,34 +143,67 @@ function directNativeKindText(kind: NativeKind): string {
}
/** 模型调用失败的可见说明:有原生分类看原生分类,没有就看平台层那一层的分类。 */
function directModelCallText(kind: ModelCallKind | null | undefined): string {
function directModelCallText(
kind: ModelCallKind | null | undefined,
detail: string | null | undefined,
): string {
// 载荷跨 IPC 没有运行时校验:`kind` 缺失时按"没有分类"兜底,别让整条事件订阅在这一步抛错。
if (!kind) {
return '智能服务执行失败,请稍后重试';
}
switch (kind.type) {
case 'responseTimedOut':
return '等待模型回执超时,本轮未完成;请稍后重试';
case 'connectionFailed':
return '执行通道未能建立或已断开,本轮未完成;请重试,若持续失败请检查项目诊断';
case 'transportBroken':
case 'streamUnavailable':
return '执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断';
case 'requestRejected':
return kind.native
? directNativeKindText(kind.native)
: '智能创作请求无效,请稍后重试';
case 'upstreamFailed':
return (
(kind.native ? directNativeKindText(kind.native) : null) ??
directUpstreamStatusText(kind.statusCode)
return withVisibleFailureDetail(
`等待模型回执超时(已尝试 ${kind.attempts} 次),本轮未完成;请稍后重试`,
detail,
);
case 'connectionFailed':
return withVisibleFailureDetail(
'执行通道连接失败,本轮未完成;请重试,若持续失败请检查项目诊断',
detail,
);
case 'transportBroken':
return withVisibleFailureDetail(
'执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断',
detail,
);
case 'streamUnavailable':
return withVisibleFailureDetail(
'流式响应协议不可用,本轮未完成;请检查项目诊断后重试',
detail,
);
case 'requestRejected':
return withVisibleFailureDetail(
kind.native
? directNativeKindText(kind.native)
: '智能创作请求无效,请稍后重试',
detail,
);
case 'upstreamFailed': {
const nativeMessage = kind.native
? directNativeKindText(kind.native)
: null;
const statusMessage = directUpstreamStatusText(kind.statusCode);
return withVisibleFailureDetail(
nativeMessage ?? statusMessage,
nativeMessage ? detail : stripUpstreamPrefix(detail, kind.statusCode),
);
}
case 'paidCreditsInsufficient':
return '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。';
return withVisibleFailureDetail(
'泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。',
detail,
);
case 'emptyResponse':
return '模型未返回内容,请重试;如持续失败请检查项目诊断';
return withVisibleFailureDetail(
'模型未返回内容,请重试;如持续失败请检查项目诊断',
detail,
);
case 'payloadInvalid':
return '模型回执无法解析,请重试;如持续失败请检查项目诊断';
return withVisibleFailureDetail(
'模型回执无法解析,请重试;如持续失败请检查项目诊断',
detail,
);
default: {
expectNever(kind);
return '智能服务执行失败,请稍后重试';
@@ -169,40 +269,64 @@ export function directTurnFailureItemId(
* 失败原因的可见文案:按载荷变体逐条写死,不再把宿主原文送进字符串映射。
*
* 改造前这里读的是宿主 `Display` 生成的整句原因,再靠 `projectRuntimeVisibleError` 做子串匹配;
* 宿主换一句事实句而前端没跟着加模式时,用户拿到的就是通用兜底。现在载荷直接给 typed 事实,
* 文案由前端在这里按变体拼:**新增变体 / 新增原生分类时这里漏一条,`expectNever` 会让编译失败**。
* 宿主换一句事实句而前端没跟着加模式时,分类仍来自 typed 载荷,安全 detail 继续保留具体原因。
* **新增变体 / 新增原生分类时这里漏一条,`expectNever` 会让编译失败**。
*
* `detail` / `cause` / `diagnostic` 一律不上屏:它们是宿主原文,可能带 `exitStatus=` /
* `stderrClass=` 这类内部字段,只用于 `.agent/runtime/errors` 与诊断。
* `detail` / `cause` / `diagnostic` 先经过精确脱敏再展示:`exitStatus=` / `stderrClass=`、HTTP
* 状态和操作系统错误码等排障字段保留,凭据值、URL 和绝对路径替换为占位符。
*/
export function directTurnFailureNoticeText(failure: TurnFailure): string {
switch (failure.type) {
case 'projectRootUnanchored':
return `${DIRECT_TURN_SUBJECT}:无法确定项目目录,本轮未能开始;请检查项目路径后重试`;
return withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT}:无法确定项目目录,本轮未能开始;请检查项目路径后重试`,
failure.cause,
);
case 'environmentNotReady':
return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,本轮未完成;请检查本机运行环境后重试`;
return withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT}:当前环境未就绪,本轮未完成;请检查本机运行环境后重试`,
failure.detail,
);
case 'hostStateUnavailable':
return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,本轮未完成;请稍后重试`;
return withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,本轮未完成;请稍后重试`,
failure.detail,
);
case 'modelCallFailed':
return `${DIRECT_TURN_SUBJECT} ${directModelCallText(failure.kind)}`;
return `${DIRECT_TURN_SUBJECT} ${directModelCallText(failure.kind, failure.detail)}`;
case 'transportClosed':
return `${DIRECT_TURN_SUBJECT} 服务连接已断开,请稍后重试`;
return withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT} 服务连接已断开,请稍后重试`,
failure.diagnostic,
);
case 'timedOut':
return `${DIRECT_TURN_SUBJECT} ${directTurnDeadlineText(failure.deadline)}`;
case 'turnInterrupted':
return `${DIRECT_TURN_SUBJECT} 本轮执行被中断,请重试`;
return withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT} 本轮执行被中断,请重试`,
failure.detail,
);
case 'superErrorFromStringPlusStage': {
// 载荷跨 IPC 没有运行时校验:stage 缺失或是更新后端新增的取值时查不到标签,
// 不能把 `undefined` 拼进用户可见文案,回落到通用句。
const stageLabel = DIRECT_FAILURE_STAGE_LABELS[failure.stage];
return stageLabel
? `${DIRECT_TURN_SUBJECT}:${stageLabel},请检查项目诊断后重试`
: `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`;
? withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT}:${stageLabel},请检查项目诊断后重试`,
failure.detail,
)
: withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`,
failure.detail,
);
}
case 'unclassified':
return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`;
return withVisibleFailureDetail(
`${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`,
failure.detail,
);
case 'hostDropped':
return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。';
return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),没有收到更具体的错误回执;本轮已按失败收口,请检查应用日志后再重试。';
default: {
expectNever(failure);
// 结构化映射外(未来宿主的新变体):给一句通用话,绝不能把原文回落给用户。
@@ -31,4 +31,66 @@ describe('DirectProject 上游失败文案', () => {
expect(text).toContain('模型上下文已超限');
expect(text).not.toContain('HTTP 429');
});
it('保留上游正文,同时只替换凭据值、链接和本地路径', () => {
const text = directTurnFailureNoticeText({
type: 'modelCallFailed',
kind: { type: 'upstreamFailed', statusCode: 502, native: null },
detail:
'LLM 上游返回 502:upstream overloaded;authorization=Bearer provider-secret;请查看 C:\\Users\\demo\\game;https://provider.example/private',
});
expect(text).toContain('HTTP 502');
expect(text).toContain('upstream overloaded');
expect(text).toContain('[已隐藏凭据]');
expect(text).not.toContain('provider-secret');
expect(text).not.toContain('provider.example');
expect(text).not.toContain('C:\\Users\\demo');
});
it.each([
['connectionFailed', 'connect ECONNRESET 127.0.0.1:3080'],
['transportBroken', 'IPC write failed: broken pipe;osError=EPIPE'],
[
'streamUnavailable',
'stream tool_calls declared but no tool slot was received',
],
] as const)('显示 %s 的真实 detail,而不是通用中断句', (kind, detail) => {
const text = directTurnFailureNoticeText({
type: 'modelCallFailed',
kind:
kind === 'connectionFailed'
? { type: 'connectionFailed', attempts: 3 }
: kind === 'transportBroken'
? { type: 'transportBroken' }
: { type: 'streamUnavailable' },
detail,
});
expect(text).toContain(detail);
expect(text).not.toBe(
'陶泥儿智能创作 执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断',
);
});
it('保留内存不足和 IPC 错误文本', () => {
const text = directTurnFailureNoticeText({
type: 'transportClosed',
diagnostic:
'IPC channel closed: out of memory (ENOMEM) while reading app-server response',
});
expect(text).toContain('out of memory (ENOMEM)');
expect(text).toContain('IPC channel closed');
});
it('环境错误保留可行动的宿主 detail', () => {
const text = directTurnFailureNoticeText({
type: 'environmentNotReady',
detail: 'Codex app-server 启动失败:spawn ENOENT;Node runtime not found',
});
expect(text).toContain('spawn ENOENT');
expect(text).toContain('Node runtime not found');
});
});
@@ -101,6 +101,15 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
默认选中快照中的全部事件、只由通知中的「查看并报告」打开、poisoned 快照用 fallback 等承诺保持不变;
本次只保证"不该进池的东西不再进池"。
### 5. DirectProject 失败正文按精确脱敏展示(2026-10-04)
- `turn.completed.failure` 中的 `detail` / `diagnostic` 不再一律隐藏。前端按 typed 变体展示安全正文,保留 HTTP 状态、错误码、IPC/stdio、操作系统错误(例如 `EPIPE`、`ENOMEM`、`ENOENT`)和上游可行动说明。
- 脱敏只替换敏感值、完整 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!`。
- HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。
- 真正没有可读事实的 `hostDropped` 仍显示宿主任务提前结束,并明确说明只能继续查应用日志;它不再冒充具体网络错误。
## 后果与边界
- auth 三命令(`login_client_with_password`、`login_client_with_phone_code`、`send_client_phone_login_code`)
@@ -0,0 +1,34 @@
# AGC 错误具体文本展示实施计划
Version: 1.0
Status: implemented-awaiting-runtime-acceptance
Date: 2026-10-04
Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md`
## 修改边界
1. 在前端回合失败映射中新增统一的安全 detail 投影,按 typed variant 保留 HTTP 状态、错误分类和脱敏文本。
2. 对 `ModelCallKind`、`TransportClosed`、`TurnInterrupted`、阶段失败和 `Unclassified` 使用各自载荷,不再无条件返回通用文案。
3. 检查并补强 Rust 错误脱敏测试,确保敏感值替换而不是整行删除。
4. 补充 upstream、transport、stream、IPC、内存不足与 HostDropped 边界测试。
5. 更新 ADR 的当前行为口径。
## 实现顺序
先锁定脱敏输入输出用例,再实现前端可见文案;随后补 Rust 映射/诊断测试,最后运行类型、编码和差异检查。
## 风险与回滚
- 风险:直接展示未经精确脱敏的 provider detail 会泄露凭据或项目路径;前端和 Rust 双重检查,发现敏感 assignment 时替换值,不删除整条错误。
- 风险:错误文本过长遮住分类;保留分类前缀并将 detail 限制在有界字符数。
- 回滚:恢复 `directTurnFailureNoticeText` 的分类文案,保留 typed detail 和诊断落盘,不改协议。
## 验证结果
- 前端 DirectProject 错误展示:15 passed。
- AGC shell TypeScript 类型检查:通过。
- Rust app-server 上游 detail 映射:6 passed。
- CC sidecar 状态码/超时映射与非支付 409 回归通过;侧车非零退出、RPC error、解析失败统一补充有界 stderr detail。
- 真实 AGC dev smoke 复现并定位 `Reached maximum number of turns (8)` → `transport-closed`;修复后客户端已热重编译重启,未再次触发付费 Provider 请求。
- Rust 应用日志/启动诊断精确脱敏回归:通过。
- 真实 Provider、IPC 断链和内存压力尚未执行。
@@ -0,0 +1,40 @@
# AGC 错误具体文本展示
Version: 1.0
Status: implemented-awaiting-runtime-acceptance
Date: 2026-10-04
Parent Spec: `docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md`
## 目标
DirectProject 回合失败在确认不是客户端内部不可归类故障时,向用户显示可行动的脱敏事实:上游 HTTP 状态、上游返回文本、网络/IPC/进程传输原因、流协议错误、内存不足等操作系统错误。保留机器字段用于诊断,但不再用通用“执行通道中断”覆盖可识别原因。
## 范围
- `turn.completed.failure` 的前端展示:按 typed 变体显示分类、状态码和脱敏后的 `detail` / `diagnostic`。
- Rust 侧错误文本脱敏:只替换敏感值、URL、绝对路径和私钥内容,保留 HTTP 状态、错误码、字段名和可行动描述。
- Transport / Stream / IPC / host process / memory exhaustion 的回归测试与错误事件证据。
## 不做
- 不把 access token、Cookie、API Key、私钥、完整 URL 查询参数、绝对路径原文显示给用户。
- 不改变上游协议、重试预算、计费和项目写入安全边界。
- 无法获得任何事实的 `hostDropped` 仍保留为客户端宿主兜底,并明确说明“未收到更具体回执”。
## 验收标准
1. HTTP 401/403/408/429/5xx 显示状态码与脱敏后的上游错误文本。
2. `connectionFailed`、`transportBroken`、`streamUnavailable`、`transportClosed` 显示对应分类及安全 detail;detail 不为空时不得落到通用错误句。
3. 包含 `authorization=...`、`token=...`、Cookie、URL query、Windows/Unix 路径、私钥块的错误,只替换敏感值并保留同一行中的状态码和其它诊断字段。
4. 包含 `out of memory`、`ENOMEM`、Windows 内存不足文本或 IPC/stdio 失败文本时,用户能看到对应安全文本。
5. HostDropped 只在确实没有可读错误事实时出现;所有显式捕获的 panic/transport/IPC 错误继续走 typed failure。
6. 运行前端 DirectProject 错误映射测试、Rust redaction/runtime_error/turn_error 定向测试、TypeScript 类型检查、编码检查和 `git diff --check`。
## 当前证据
- `npx vitest run tests/directTurnFailure.test.ts`:15 passed。
- `npx tsc --noEmit -p apps/ai-game-creator-shell/tsconfig.json`:通过。
- Rust app-server 上游映射定向测试:6 passed;应用日志脱敏测试与启动诊断脱敏测试均通过。
- CC 错误分类回归:Claude sidecar 状态码/超时映射与非支付 409 保留上游状态的定向测试通过;侧车失败统一补充有界 stderr detail。
- 真实 AGC dev smoke:客户端使用 `3080`、后端 `8084`、数据库 `3101`、后台 `3103` 启动;真实 CC 回合复现 `Reached maximum number of turns (8)` 被旧代码错误记为 `transport-closed`,修复后 Tauri 已热重编译重启。修复后的真实 Provider 回放未再次发送,避免无必要的付费请求。
- 真实 Provider、真实 IPC 断链和真实内存压力尚未在本轮执行。
@@ -81,7 +81,7 @@ Node/npm 版本探测清空继承环境后,必须设置客户端创建的临
- **网络与凭据只在 Rust**。平台 origin、Bearer/refresh 凭据、OSS 直传票据、Provider 与更新清单请求都由 `src-tauri` 承担;渲染层通过 typed command 提交结构化意图,不再持有 access token,也不再声明 `http:default` 权限(`capabilities/main.json`)。`src/services/clientApi.ts` 与 `fetchClientHttp` 已删除。
- **状态变更由 Rust 事件驱动**。正式状态归 Rust:Direct 活动回合的唯一事实源是 Direct 线程管理器的活动回合快照(`list_direct_active_turns` 只读它),登记、进度内容变化、收口各广播一次 `game-creator-direct-active-turns-changed`;素材生成与插件状态同理。渲染层进入入口时**先订阅、再读一次受控快照**,事件重复或内容未变时保持数组身份,卸载后迟到事件不写回;不恢复任何固定频率轮询(纯 UI 计时器、拖拽重复器与动画 tick 除外)。
- **维护态判定归 Rust,渲染层只订阅**。平台请求的错误分支统一经 `platform_maintenance::watch_platform_response` 分类(只有 `503` 且命中 `MAINTENANCE` 或「维护」才算,对象存储自身的 503 不误伤),命中后广播 `genarrative-client-maintenance-detected`;渲染层由 `clientMaintenance.subscribeClientMaintenanceEvent` 订阅并打开唯一的「系统维护中」弹窗,业务面板各自的错误文案保持不变,同一批并发失败只弹一次。
- **失败文案只展示宿主给的脱敏摘要**。`turn.completed.failure` 是失败说明的唯一来源,渲染层不做 HTTP 判定、不预读诊断正文;失败线索留在 `.agent/runtime/errors`、应用日志与错误上报池(`5398a53e6` 起用户可见文案不再带诊断引用)。
- **失败文案展示宿主给的精确脱敏事实**。`turn.completed.failure` 是失败说明的唯一来源,渲染层按 typed 变体展示 HTTP 状态、错误码、IPC/stdio、操作系统错误和上游正文;只替换凭据值、完整 URL、绝对路径与私钥,不再因命中 `authorization/token/credential` 删除整条错误。失败线索仍写入 `.agent/runtime/errors`、应用日志与错误上报池。
- **活动回合之外的两条配套约束**:命令返回 `Ok` 只代表接单成立,整轮收场只由 `turn.completed` 回答;渲染层的队列、忙态与提示都以事件流的这些终态为准。
证据入口:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --offline -- direct_thread_manager --test-threads=1`、`-- platform_maintenance --test-threads=1`、`npx vitest run tests/directActiveTurns.test.tsx tests/maintenanceNotice.test.tsx --root apps/ai-game-creator-shell`。