diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index eeedc2dfd..08348c123 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -20,6 +20,8 @@ const GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX: usize = 128; const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000; pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str = "codex-app-server-terminal-unknown:"; +pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str = + "codex-app-server-error:"; type RpcResult = Result; @@ -177,6 +179,92 @@ fn game_creator_codex_app_server_terminal_unknown( )) } +fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmError { + platform_llm::LlmError::InvalidRequest(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}" + )) +} + +fn game_creator_codex_app_server_error_http_status( + info: &serde_json::Value, + field: &str, +) -> Option { + info.get(field)? + .get("httpStatusCode")? + .as_u64() + .and_then(|status| u16::try_from(status).ok()) + .filter(|status| (100..=599).contains(status)) +} + +fn game_creator_codex_app_server_connection_error( + info: &serde_json::Value, + field: &str, +) -> 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(status_code) => platform_llm::LlmError::Upstream { + status_code, + message: "Codex app-server 连接上游失败".to_string(), + }, + None => platform_llm::LlmError::Connectivity { + attempts: 1, + message: "Codex app-server 连接失败".to_string(), + }, + } +} + +fn game_creator_codex_app_server_failed_turn_error( + turn: &serde_json::Value, +) -> platform_llm::LlmError { + let Some(info) = turn + .get("error") + .and_then(|error| error.get("codexErrorInfo")) + .filter(|info| !info.is_null()) + else { + return game_creator_codex_app_server_error_kind("other"); + }; + if let Some(kind) = info.as_str() { + return match kind { + "contextWindowExceeded" => { + game_creator_codex_app_server_error_kind("context-window-exceeded") + } + "sessionBudgetExceeded" => { + game_creator_codex_app_server_error_kind("session-budget-exceeded") + } + "usageLimitExceeded" => { + game_creator_codex_app_server_error_kind("usage-limit-exceeded") + } + "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream { + status_code: 503, + message: "Codex app-server 上游服务暂时不可用".to_string(), + }, + "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") + } + "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), + "other" => game_creator_codex_app_server_error_kind("other"), + _ => game_creator_codex_app_server_error_kind("other"), + }; + } + for field in [ + "httpConnectionFailed", + "responseStreamConnectionFailed", + "responseStreamDisconnected", + "responseTooManyFailedAttempts", + ] { + if info.get(field).is_some() { + return game_creator_codex_app_server_connection_error(info, field); + } + } + if info.get("activeTurnNotSteerable").is_some() { + return game_creator_codex_app_server_error_kind("active-turn-not-steerable"); + } + game_creator_codex_app_server_error_kind("other") +} + async fn isolate_game_creator_codex_app_server_terminal_unknown( inner: &Arc, detail: impl Into, @@ -1012,9 +1100,7 @@ impl CodexAppServerConnection { )) } "failed" => { - return Err(platform_llm::LlmError::InvalidRequest( - "Codex app-server turn 执行失败".to_string(), - )) + return Err(game_creator_codex_app_server_failed_turn_error(turn)) } status => { return Err(platform_llm::LlmError::Deserialize(format!( @@ -1577,6 +1663,81 @@ mod tests { assert!(response.text.is_empty()); } + #[test] + fn codex_app_server_failed_turn_uses_structured_error_info_without_raw_details() { + let secret = ["sk", "turn-secret"].join("-"); + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { + "message": format!("private message {secret}"), + "additionalDetails": "https://provider.example/private C:\\Users\\victim\\project", + "codexErrorInfo": "contextWindowExceeded" + } + }); + 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.contains(&secret)); + assert!(!visible.contains("provider.example")); + assert!(!visible.contains("victim")); + } + + #[test] + fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { + for (info, expected) in [ + ( + serde_json::json!("usageLimitExceeded"), + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:usage-limit-exceeded".to_string(), + ), + ), + ( + serde_json::json!("unauthorized"), + platform_llm::LlmError::InvalidRequest( + "codex-app-server-error:unauthorized".to_string(), + ), + ), + ( + serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), + platform_llm::LlmError::Upstream { + status_code: 429, + message: "Codex app-server 连接上游失败".to_string(), + }, + ), + ( + serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}), + platform_llm::LlmError::Connectivity { + attempts: 1, + message: "Codex app-server 连接失败".to_string(), + }, + ), + ] { + let turn = serde_json::json!({ + "status": "failed", + "error": { + "message": "private upstream body", + "additionalDetails": "private diagnostics", + "codexErrorInfo": info + } + }); + assert_eq!( + game_creator_codex_app_server_failed_turn_error(&turn), + expected + ); + } + assert_eq!( + game_creator_codex_app_server_failed_turn_error( + &serde_json::json!({"status":"failed","error":null}) + ), + platform_llm::LlmError::InvalidRequest("codex-app-server-error:other".to_string()) + ); + } + #[test] fn codex_app_server_rejects_non_responses_key_mapping() { let mut llm = test_llm(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index dd84942a3..43a6aa0ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -547,7 +547,13 @@ pub(crate) fn game_creator_agent_llm_error_public_summary( (format!("upstream-{status_code}"), Some(*status_code)) } platform_llm::LlmError::InvalidConfig(_) => ("invalid-config".to_string(), None), - platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None), + platform_llm::LlmError::InvalidRequest(message) => ( + message + .strip_prefix(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX) + .map(|kind| format!("codex-app-server-{kind}")) + .unwrap_or_else(|| "invalid-request".to_string()), + None, + ), platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None), platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None), platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 3f31ca775..fa6ecd3fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -60,6 +60,25 @@ pub(super) fn game_creator_agent_background_final_reply_fallback( } } +pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) -> bool { + const KIND_PREFIX: &str = "kind="; + let Some(start) = error.rfind(KIND_PREFIX) else { + return false; + }; + if error[..start] + .chars() + .next_back() + .is_some_and(|boundary| !boundary.is_whitespace() && boundary != ':' && boundary != ':') + { + return false; + } + let kind = error[start + KIND_PREFIX.len()..] + .chars() + .take_while(|character| character.is_ascii_lowercase() || *character == '-') + .collect::(); + matches!(kind.as_str(), "empty-response" | "deserialize") +} + fn requested_game_chat_fast_path_plan_at( root: &Path, plan: AgentRuntimeToolPlan, @@ -3799,7 +3818,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { return AgentBackgroundTaskOutcome::NeedsReconciliation; } - Err(_) if final_reply_fallback.is_some() => { + Err(error) + if final_reply_fallback.is_some() + && game_creator_agent_final_reply_error_allows_fallback(&error) => + { final_reply_fallback.expect("checked final reply fallback") } Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index df9c4e0a5..9832033f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -3294,6 +3294,36 @@ fn plan_response_precedes_autonomous_supervisor_deterministic_fallback() { ); } +#[test] +fn autonomous_final_reply_fallback_only_accepts_safe_response_shape_failures() { + let fingerprint = "a".repeat(64); + for allowed in ["empty-response", "deserialize"] { + assert!(game_creator_agent_final_reply_error_allows_fallback( + &format!("后台 Agent 最终回复调用 LLM 失败:kind={allowed} fingerprint={fingerprint} chars=12") + )); + } + for rejected in [ + "codex-app-server-unauthorized", + "codex-app-server-usage-limit-exceeded", + "codex-app-server-context-window-exceeded", + "codex-app-server-cyber-policy", + "codex-app-server-sandbox-error", + "invalid-config", + "transport", + "upstream-503", + ] { + assert!( + !game_creator_agent_final_reply_error_allows_fallback(&format!( + "后台 Agent 最终回复调用 LLM 失败:kind={rejected} fingerprint={fingerprint} chars=12" + )), + "final reply fallback must reject {rejected}" + ); + } + assert!(!game_creator_agent_final_reply_error_allows_fallback( + "上游自由文本kind=deserialize fingerprint=private" + )); +} + #[tokio::test] async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallback_once() { const RUN_ID: &str = "autonomous-final-reply-fallback-run"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 0a15a6860..ef3faa7bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -204,6 +204,40 @@ pub(in crate::agent) fn game_creator_agent_runtime_failure_conversation_message( } else { "专业 Agent" }; + let public_kind_prefix = "kind=codex-app-server-"; + let codex_error_kind = error + .rfind(public_kind_prefix) + .map(|start| &error[start + public_kind_prefix.len()..]) + .or_else(|| { + error + .rfind(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX) + .map(|start| { + &error[start + GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX.len()..] + }) + }) + .map(|kind| { + kind.chars() + .take_while(|character| character.is_ascii_lowercase() || *character == '-') + .collect::() + }) + .filter(|kind| !kind.is_empty()); + if let Some(kind) = codex_error_kind { + let detail = match kind + .trim_matches(|character: char| !character.is_ascii_lowercase() && character != '-') + { + "context-window-exceeded" => "模型上下文已超限,请缩小任务范围后重试", + "session-budget-exceeded" => "本次会话预算已耗尽,请缩小任务范围或新建任务", + "usage-limit-exceeded" => "Codex 用量已达上限,请检查账户额度后重试", + "unauthorized" => "Codex 鉴权失败,请重新登录或检查 API Key", + "bad-request" => "Codex 请求无效,请检查模型与运行时配置", + "cyber-policy" => "Codex 安全策略拒绝了本次请求,请调整任务内容", + "sandbox-error" => "Codex 隔离环境启动失败,请重试或检查本机环境", + "thread-rollback-failed" => "Codex 会话恢复失败,请新建任务后重试", + "active-turn-not-steerable" => "当前 Codex 任务无法追加指令,请等待结束后重试", + _ => "Codex 执行失败,请查看运行详情后重试", + }; + return format!("{subject} {detail}"); + } if let Some((http_status, retry_attempt, max_retries)) = game_creator_agent_runtime_exhausted_upstream_retry_fields(error) { @@ -1956,4 +1990,43 @@ mod tests { assert!(!unrelated_visible.contains("absolute-path")); assert!(!unrelated_visible.contains("redacted-secret")); } + + #[test] + fn codex_app_server_failure_kind_has_actionable_safe_public_summary() { + let private_error = format!( + "agentLlm.code-prototype 调用 LLM 失败:kind=codex-app-server-context-window-exceeded fingerprint={} chars=999", + "a".repeat(64) + ); + assert_eq!( + game_creator_agent_runtime_failure_conversation_message( + "code-prototype", + &private_error, + ), + "专业 Agent 模型上下文已超限,请缩小任务范围后重试" + ); + let unauthorized = format!( + "kind=codex-app-server-unauthorized fingerprint={} chars=32", + "b".repeat(64) + ); + assert_eq!( + game_creator_agent_runtime_failure_conversation_message( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &unauthorized, + ), + "项目总控 Agent Codex 鉴权失败,请重新登录或检查 API Key" + ); + for visible in [ + game_creator_agent_runtime_failure_conversation_message( + "code-prototype", + &private_error, + ), + game_creator_agent_runtime_failure_conversation_message( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &unauthorized, + ), + ] { + assert!(!visible.contains("fingerprint")); + assert!(!visible.contains("chars=")); + } + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 820ea4827..8ca059907 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -2599,6 +2599,21 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( ) })?; } + let failure_detail = matches!( + event_type, + "error" | "turn.failed" | "turn.budget_exhausted" + ) + .then(|| { + detail.map(|value| game_creator_agent_runtime_public_failure_detail(&state.agent_id, value)) + }) + .flatten(); + let public_text = if matches!(event_type, "turn.failed" | "turn.budget_exhausted") { + failure_detail + .clone() + .or_else(|| game_creator_agent_runtime_public_event_text(root, event_type, summary)) + } else { + game_creator_agent_runtime_public_event_text(root, event_type, summary) + }; let event = AgentRuntimeEvent { schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: state.agent_id.clone(), @@ -2612,7 +2627,7 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( status: status.to_string(), phase: phase.to_string(), summary: summary.to_string(), - public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary), + public_text, detail: detail .filter(|_| { !(event_type == "observation" @@ -2624,10 +2639,9 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( event_type, "error" | "turn.failed" | "turn.budget_exhausted" ) { - return game_creator_agent_runtime_public_failure_detail( - &state.agent_id, - value, - ); + return failure_detail.clone().unwrap_or_else(|| { + game_creator_agent_runtime_public_failure_detail(&state.agent_id, value) + }); } let max_chars = if event_type == "observation" && summary.starts_with("agent.action_history:") diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index d0fe0840b..8bf085900 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1628,17 +1628,23 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( .write_all(planning_response.as_bytes()) .expect("mock tool plan response"); - let (mut final_stream, _) = listener.accept().expect("mock final reply accept"); - drop(read_mock_http_request(&mut final_stream)); - let invalid_body = "{invalid-json"; - let final_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - invalid_body.len(), - invalid_body - ); - final_stream - .write_all(final_response.as_bytes()) - .expect("mock invalid final reply response"); + // Autonomous runs enforce a 12-retry floor. Return the same malformed + // response for the initial final-reply request and every retry so this + // fixture tests deserialize exhaustion rather than an accidental + // connection-refused fallback after the first malformed response. + for _ in 0..=12 { + let (mut final_stream, _) = listener.accept().expect("mock final reply accept"); + drop(read_mock_http_request(&mut final_stream)); + let invalid_body = "{invalid-json"; + let final_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + invalid_body.len(), + invalid_body + ); + final_stream + .write_all(final_response.as_bytes()) + .expect("mock invalid final reply response"); + } }); base_url } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 4d013debf..d77a1e92c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -147,12 +147,23 @@ fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() { "public-event-action-1", ) .expect("repeat public action idempotently"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "turn.failed", + "failed", + "failed", + "Agent Runtime 本轮处理失败。", + Some("kind=codex-app-server-context-window-exceeded fingerprint=private"), + "public-event-failure-1", + ) + .expect("append classified public failure"); let events = read_recent_game_creator_agent_runtime_events( &game_creator_agent_runtime_event_path(&root, "code-prototype"), ) .expect("read public runtime events"); - assert_eq!(events.len(), 4); + assert_eq!(events.len(), 5); assert!(events.iter().all(|event| !event.event_id.trim().is_empty())); let mut event_ids = events .iter() @@ -176,6 +187,16 @@ fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() { .as_deref() .unwrap_or_default() .contains("raw tool input must stay private")); + assert_eq!( + events[4].public_text.as_deref(), + Some("专业 Agent 模型上下文已超限,请缩小任务范围后重试") + ); + assert_eq!(events[4].detail, events[4].public_text); + assert!(!events[4] + .public_text + .as_deref() + .unwrap_or_default() + .contains("fingerprint")); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 6cf04d7c7..d97a3ca63 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -846,6 +846,7 @@ export interface AgentStatusCard { runtimeWaitingOn: string | null; runtimeNextStep: string | null; runtimeTask: string | null; + runtimeError?: string | null; runtimeRunId: string | null; runtimeLoopIteration: number | null; runtimeMaxLoopIterations: number | null; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 1135bc779..88d45c55c 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -703,8 +703,16 @@ export function agentRuntimeStartedRunId( export function isAgentRuntimeTerminalState(runtime: AgentRuntimeState) { return ( - ['completed', 'failed', 'cancelled'].includes(runtime.phase) || - ['completed', 'failed', 'cancelled', 'idle'].includes(runtime.status) + ['completed', 'failed', 'cancelled', 'needs-reconciliation'].includes( + runtime.phase, + ) || + [ + 'completed', + 'failed', + 'cancelled', + 'idle', + 'needs-reconciliation', + ].includes(runtime.status) ); } @@ -973,6 +981,12 @@ function agentRuntimeProviderRetryStatus(runtime: AgentRuntimeState) { export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) { if (isAgentRuntimeTerminalState(runtime)) { + if ( + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ) { + return 'Agent 运行状态需要核对,正在同步记录'; + } if (runtime.status === 'failed' || runtime.phase === 'failed') { return 'Agent 运行失败,正在同步错误记录'; } @@ -1017,6 +1031,14 @@ export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) { if (runtime.status === 'idle' || runtime.phase === 'idle') { return '等待输入'; } + if ( + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ) { + return runtime.error + ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true) + : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试'; + } if (isAgentRuntimeTerminalState(runtime)) { if (runtime.status === 'failed' || runtime.phase === 'failed') { return runtime.error @@ -1037,15 +1059,13 @@ export function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) { 'turn.failed', 'turn.budget_exhausted', ].includes(event.eventType); - const containsInternalFailureDiagnostics = Boolean( - event.detail && - /(?:errorSha256|errorChars|fingerprint|chars|retryAttempt|retryState)=|<(?:absolute-path|redacted-url)>|\[redacted(?:[- ]secret| sensitive context)\]/i.test( - event.detail, - ), - ); - const visibleDetail = - isFailureEvent && containsInternalFailureDiagnostics ? null : event.detail; + const visibleDetail = isFailureEvent ? null : event.detail; + const publicFailureText = + isFailureEvent && typeof event.publicText === 'string' + ? event.publicText.trim() + : ''; const summary = + publicFailureText || event.summary || visibleDetail || (isFailureEvent ? 'Agent Runtime 本轮处理失败。' : event.runId); @@ -1365,11 +1385,16 @@ export function projectSupervisorRuntimeStatusLabel( runtime: AgentRuntimeState | null, runtimeError: string, ) { + if ( + runtime?.status === 'needs-reconciliation' || + runtime?.phase === 'needs-reconciliation' + ) { + return '待核对'; + } if ( runtimeError || runtime?.status === 'failed' || - runtime?.phase === 'failed' || - runtime?.phase === 'needs-reconciliation' + runtime?.phase === 'failed' ) { return '失败'; } @@ -1627,6 +1652,27 @@ export function projectRuntimeVisibleError( if (isMudPointInsufficientRuntimeError(message)) { return MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE; } + const codexAppServerKind = visibleMessage.match( + /(?:^|[\s::])kind=codex-app-server-([a-z-]+)(?=\s|$)/, + )?.[1]; + if (codexAppServerKind) { + const detail = { + 'context-window-exceeded': '模型上下文已超限,请缩小任务范围后重试', + 'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务', + 'usage-limit-exceeded': 'Codex 用量已达上限,请检查账户额度后重试', + unauthorized: 'Codex 鉴权失败,请重新登录或检查 API Key', + 'bad-request': 'Codex 请求无效,请检查模型与运行时配置', + 'cyber-policy': 'Codex 安全策略拒绝了本次请求,请调整任务内容', + 'sandbox-error': 'Codex 隔离环境启动失败,请重试或检查本机环境', + 'thread-rollback-failed': 'Codex 会话恢复失败,请新建任务后重试', + 'active-turn-not-steerable': + '当前 Codex 任务无法追加指令,请等待结束后重试', + other: 'Codex 执行失败,请查看运行详情后重试', + }[codexAppServerKind]; + if (detail) { + return `${subject} ${detail}`; + } + } const exhaustedUpstreamRetry = visibleMessage.match( /(?:^|[\s::])kind=upstream-(\d{3}) httpStatus=(\d{3}) fingerprint=[0-9a-f]{64} chars=\d+ retryAttempt=(\d+) maxRetries=(\d+) retryState=exhausted\s*$/, ); @@ -1684,6 +1730,54 @@ export function projectRuntimeVisibleError( ) { return `${subject} 服务繁忙,请稍后重试`; } + if ( + normalized.includes('needs-reconciliation') || + normalized.includes('result-unknown') || + normalized.includes('终态未知') || + normalized.includes('需要人工核对') + ) { + return `${subject} 运行状态需要核对,请打开运行详情后重试`; + } + if ( + normalized.includes('budget-exhausted') || + normalized.includes('预算耗尽') || + normalized.includes('预算已耗尽') + ) { + return `${subject} 本轮预算已耗尽,请缩小任务范围后重试`; + } + if ( + normalized.includes('missing expected artifact') || + normalized.includes('expected artifact') || + normalized.includes('缺少预期产物') || + normalized.includes('缺少 expected artifact') + ) { + return `${subject} 未生成要求的产物,请查看任务要求后重试`; + } + if ( + normalized.includes('verification') || + normalized.includes('project.verify') || + normalized.includes('preview.validate') || + normalized.includes('验证未通过') || + normalized.includes('验证失败') + ) { + return `${subject} 项目验证未通过,请查看运行详情并修复后重试`; + } + if ( + normalized.includes('policy') || + normalized.includes('permission') || + normalized.includes('拒绝') || + normalized.includes('禁止') || + normalized.includes('不允许') + ) { + return `${subject} 被项目权限或安全策略阻止,请检查审批配置`; + } + if ( + normalized.includes('落盘失败') || + normalized.includes('持久化失败') || + normalized.includes('写入失败') + ) { + return `${subject} 保存运行记录失败,请检查项目目录后重试`; + } const containsInternalDiagnostics = normalized.includes('agentllm.') || /(?:^|[\s::])kind=/.test(normalized) || @@ -1711,7 +1805,8 @@ export function projectRuntimeVisibleError( export function projectSupervisorVisibleConversationText( message: string, - role: ChatMessage['role'] = 'assistant', + role: ChatMessage['role'] | 'tool' = 'assistant', + subject = '项目总控 Agent', ) { const failurePrefix = '后台任务失败:'; if (role !== 'assistant' || !message.startsWith(failurePrefix)) { @@ -1719,7 +1814,7 @@ export function projectSupervisorVisibleConversationText( } return projectRuntimeVisibleError( message.slice(failurePrefix.length), - '项目总控 Agent', + subject, true, ); } @@ -1834,9 +1929,22 @@ export function formatAgentRecentRuntimeTask(task: AgentRuntimeTaskRecord) { const goalSource = task.goalId ? `Goal ${task.goalStatus ?? '-'} · revision ${task.goalRevision ?? 0}` : null; + const failed = + task.status === 'failed' || + ['failed', 'budget-exhausted', 'needs-reconciliation'].includes(task.phase); + const failureDetail = failed + ? task.terminalDetail?.trim() || task.error?.trim() || null + : null; + const failureSummary = failureDetail + ? projectRuntimeVisibleError( + failureDetail, + projectProfessionalAgentLabel(task.agentId), + true, + ) + : null; return `${task.status} / ${task.phase} · ${ task.task || task.currentAction || task.runId }${goalSource ? ` · ${goalSource}` : ''}${ delegationSource ? ` · ${delegationSource}` : '' - }`; + }${failureSummary ? ` · 失败原因:${failureSummary}` : ''}`; } diff --git a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts index 88f6c7361..5551fdca8 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts @@ -38,6 +38,8 @@ import { formatAgentRuntimeTaskQueue, isAgentRuntimeTerminalState, projectRuntimeVisibleCurrentWork, + projectRuntimeVisibleError, + projectProfessionalAgentLabel, taskRowsFromManifest, } from '../agent-runtime'; import { @@ -153,6 +155,26 @@ export function taskRowsForAgentStatus( return mergedTasks; } +function agentRuntimeFailureDetail(runtime: AgentRuntimeState) { + const runtimeError = runtime.error?.trim(); + if (runtimeError) { + return runtimeError; + } + const terminalTask = [...(runtime.recentTasks ?? [])] + .reverse() + .find( + (task) => + task.runId === runtime.runId && + (task.status === 'failed' || + ['failed', 'budget-exhausted', 'needs-reconciliation'].includes( + task.phase, + )), + ); + return ( + terminalTask?.terminalDetail?.trim() || terminalTask?.error?.trim() || null + ); +} + export function deriveAgentStatusCards( nextManifest: GameCreationAppManifest, trace: GameCreationAgentRunTrace | null, @@ -204,6 +226,7 @@ export function deriveAgentStatusCards( ? (runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase)) : null, runtimeTask: runtime?.currentTask ?? null, + runtimeError: runtime ? agentRuntimeFailureDetail(runtime) : null, runtimeRunId: runtime?.runId ?? null, runtimeLoopIteration: runtime?.loopIteration ?? null, runtimeMaxLoopIterations: runtime?.maxLoopIterations ?? null, @@ -247,7 +270,12 @@ export function projectAgentRuntimeSummaries( ) { return 4; } - if (runtime.status === 'failed' || runtime.phase === 'failed') { + if ( + runtime.status === 'failed' || + runtime.phase === 'failed' || + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ) { return 3; } if (!isAgentRuntimeTerminalState(runtime)) { @@ -296,6 +324,9 @@ export function projectAgentRuntimeSummaries( runtime.phase === 'waiting-for-confirmation', ); const failed = runtime.status === 'failed' || runtime.phase === 'failed'; + const needsReconciliation = + runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation'; const completed = runtime.status === 'completed' || runtime.phase === 'completed'; const cancelled = @@ -304,7 +335,7 @@ export function projectAgentRuntimeSummaries( const status: ProjectAgentRuntimeSummary['status'] = waitingForInput || waitingForConfirmation ? 'waiting' - : failed + : failed || needsReconciliation ? 'failed' : completed ? 'completed' @@ -322,17 +353,28 @@ export function projectAgentRuntimeSummaries( ? '待回答' : waitingForConfirmation ? '待确认' - : failed - ? '失败' - : completed - ? '已完成' - : cancelled - ? '已取消' - : idle - ? '等待中' - : runtime.phase === 'planning' - ? '分析中' - : '工作中'; + : needsReconciliation + ? '待核对' + : failed + ? '失败' + : completed + ? '已完成' + : cancelled + ? '已取消' + : idle + ? '等待中' + : runtime.phase === 'planning' + ? '分析中' + : '工作中'; + const failureDetail = agentRuntimeFailureDetail(runtime); + const failureSummary = + (failed || needsReconciliation) && failureDetail + ? projectRuntimeVisibleError( + failureDetail, + projectProfessionalAgentLabel(runtime.agentId), + true, + ) + : null; return [ { @@ -340,6 +382,7 @@ export function projectAgentRuntimeSummaries( label, status, statusLabel, + failureSummary, currentTask: (activePlanStep && agentRuntimePlanStepText(activePlanStep)) || projectRuntimeVisibleCurrentWork(runtime), @@ -387,6 +430,7 @@ export function sameAgentStatusCard( left.runtimeWaitingOn === right.runtimeWaitingOn && left.runtimeNextStep === right.runtimeNextStep && left.runtimeTask === right.runtimeTask && + left.runtimeError === right.runtimeError && left.runtimeRunId === right.runtimeRunId && left.runtimeLoopIteration === right.runtimeLoopIteration && left.runtimeMaxLoopIterations === right.runtimeMaxLoopIterations && @@ -455,6 +499,7 @@ export function sameAgentRuntimeTasks( task.task === other.task && task.currentAction === other.currentAction && task.terminalDetail === other.terminalDetail && + task.error === other.error && task.updatedAt === other.updatedAt ); }) @@ -901,8 +946,22 @@ export function formatAgentCardRuntimeStatus(agent: AgentStatusCard) { agent.runtimeMaxLoopIterations ?? 3 }` : null; + const needsAttention = + agent.runtimeStatus === 'failed' || + agent.runtimePhase === 'failed' || + agent.runtimeStatus === 'needs-reconciliation' || + agent.runtimePhase === 'needs-reconciliation'; + const failureSummary = + needsAttention && agent.runtimeError + ? projectRuntimeVisibleError( + agent.runtimeError, + projectProfessionalAgentLabel(agent.id), + true, + ) + : null; return [ `Runtime:${agent.runtimeStatus} / ${agent.runtimePhase ?? '-'}`, + failureSummary, loopProgress, agent.runtimeAction, agent.runtimeWaitingOn ? `等待 ${agent.runtimeWaitingOn}` : null, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx index d823404ca..5b6db7c21 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx @@ -16,7 +16,11 @@ import type { AgentStatusCard, LocalConversationMessageRecord, } from '../../app/types'; -import { AgentRuntimeStatusPanel } from '../agent-runtime'; +import { + AgentRuntimeStatusPanel, + projectProfessionalAgentLabel, + projectSupervisorVisibleConversationText, +} from '../agent-runtime'; import { commandDraftFromSuggestedToolCall } from '../project-summary/agentPresentation'; import { agentTaskGraphStateLabels, @@ -268,7 +272,11 @@ export function AgentConversationOverlay({ key={`${message.updatedAt}-${index}`} className={`message message--${message.role}`} > - {message.content} + {projectSupervisorVisibleConversationText( + message.content, + message.role, + projectProfessionalAgentLabel(selectedAgent.id), + )}

))} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 7cc9d262d..b57c66ff2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -14,6 +14,9 @@ import type { } from '../../app/types'; import { ProjectSupervisorRuntimePanel, + projectProfessionalAgentLabel, + projectRuntimeVisibleError, + projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; @@ -77,7 +80,10 @@ export function ProjectSupervisorView({ key={message.messageId ?? `${message.role}-${index}`} className={`message message--${message.role}`} > - {message.text} + {projectSupervisorVisibleConversationText( + message.text, + message.role, + )}

))} {transientReply ? ( @@ -151,6 +157,15 @@ export function ProjectSupervisorView({ taskStatusLabels[agent.status]} {agent.runtimeTask ? {agent.runtimeTask} : null} + {agent.runtimeError ? ( + + {projectRuntimeVisibleError( + agent.runtimeError, + projectProfessionalAgentLabel(agent.id), + true, + )} + + ) : null} ))} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 0057e4c6a..141a298e4 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -303,7 +303,23 @@ export function formatGameChatStageRecord( ) { const terminalStatus = runtime.status === 'failed' || runtime.phase === 'failed' - ? progress.interruptionText || '本轮失败' + ? progress.interruptionText || + (runtime.error + ? projectRuntimeVisibleError( + runtime.error, + '项目总控 Agent', + true, + ) + : '本轮失败') + : runtime.status === 'needs-reconciliation' || + runtime.phase === 'needs-reconciliation' + ? runtime.error + ? projectRuntimeVisibleError( + runtime.error, + '项目总控 Agent', + true, + ) + : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试' : runtime.status === 'cancelled' || runtime.phase === 'cancelled' ? '本轮已取消' : runtime.status === 'completed' || runtime.phase === 'completed' @@ -971,7 +987,10 @@ export function SupervisorChatOnlyView({ const running = Boolean( chatAgentBusy || synchronizingAcceptedRun || - (runtime && !isAgentRuntimeTerminalState(runtime)), + (runtime && + runtime.status !== 'needs-reconciliation' && + runtime.phase !== 'needs-reconciliation' && + !isAgentRuntimeTerminalState(runtime)), ); const gameChatInterruptionText = gameChatMode && runtime @@ -1099,6 +1118,12 @@ export function SupervisorChatOnlyView({ if (runtimeError) { return '运行异常'; } + if ( + runtime?.status === 'needs-reconciliation' || + runtime?.phase === 'needs-reconciliation' + ) { + return '待人工核对'; + } if (synchronizingAcceptedRun) { return '正在启动'; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 0107ed954..c74750bc6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -219,6 +219,7 @@ export type ProjectAgentRuntimeSummary = { label: string; status: 'pending' | 'running' | 'waiting' | 'completed' | 'failed'; statusLabel: string; + failureSummary?: string | null; currentTask: string; currentAction: string | null; waitingOn: string | null; @@ -375,6 +376,7 @@ function summarizeAgent( completed: '已完成', failed: '需处理', }[status], + failureSummary: null, currentTask: activeTask?.title ?? (completedCount > 0 ? '本轮任务已完成' : '等待项目总控分配任务'), @@ -3794,7 +3796,7 @@ export default function ProjectDevelopmentView({
@@ -3811,7 +3813,7 @@ export default function ProjectDevelopmentView({ {agent.label} - {agent.statusLabel} + {agent.failureSummary ?? agent.statusLabel} { test('用项目名称替代 Unix 和 Windows 绝对路径', () => { @@ -35,7 +44,105 @@ describe('普通用户工作区状态', () => { }); }); +describe('Agent 最近任务失败摘要', () => { + test('展示安全可行动原因且不透传私有诊断', () => { + const task: AgentRuntimeTaskRecord = { + schemaVersion: 'game-creator-agent-runtime-task.v1', + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'session-code', + runId: 'run-code-failed', + source: 'agent-delegate', + task: '实现首个可玩版本', + status: 'failed', + phase: 'failed', + currentAction: '等待开发者处理失败', + terminalDetail: + 'kind=codex-app-server-context-window-exceeded fingerprint=' + + 'a'.repeat(64), + error: 'private provider body https://provider.example/api?key=secret', + updatedAt: 1, + }; + + const visible = formatAgentRecentRuntimeTask(task); + expect(visible).toContain( + '失败原因:程序原型 Agent 模型上下文已超限,请缩小任务范围后重试', + ); + expect(visible).not.toContain('fingerprint'); + expect(visible).not.toContain('provider.example'); + expect(visible).not.toContain('secret'); + }); + + test('状态卡在 Runtime error 为空时使用当前 Run 的终态任务原因', () => { + const terminalDetail = + 'kind=codex-app-server-context-window-exceeded fingerprint=' + + 'b'.repeat(64); + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'failure-card-game', + ); + const cards = deriveAgentStatusCards(manifest, null, { + 'code-prototype': { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'session-code', + runId: 'run-code-failed', + source: 'agent-delegate', + status: 'failed', + phase: 'failed', + currentTask: '实现首个可玩版本', + plan: [], + observations: [], + allowedTools: [], + error: null, + updatedAt: 2, + recentTasks: [ + { + schemaVersion: 'game-creator-agent-runtime-task.v1', + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'session-code', + runId: 'run-code-failed', + source: 'agent-delegate', + task: '实现首个可玩版本', + status: 'failed', + phase: 'failed', + currentAction: '等待开发者处理失败', + terminalDetail, + error: null, + updatedAt: 2, + }, + ], + }, + }); + const card = cards.find((candidate) => candidate.id === 'code-prototype'); + expect(card?.runtimeError).toBe(terminalDetail); + expect(formatAgentCardRuntimeStatus(card!)).toContain( + '程序原型 Agent 模型上下文已超限,请缩小任务范围后重试', + ); + expect(formatAgentCardRuntimeStatus(card!)).not.toContain('fingerprint'); + }); +}); + describe('Runtime-owned public statuses', () => { + test('treats needs-reconciliation as a terminal state that requires manual resolution', () => { + const runtime = { + ...providerRetryRuntime(), + status: 'needs-reconciliation', + phase: 'needs-reconciliation', + error: 'result-unknown,需要人工核对', + }; + + expect(isAgentRuntimeTerminalState(runtime)).toBe(true); + expect(agentRuntimeConversationStatus(runtime)).toBe( + 'Agent 运行状态需要核对,正在同步记录', + ); + expect(projectSupervisorChatRuntimeStatus(runtime)).toBe( + '项目总控 Agent 运行状态需要核对,请打开运行详情后重试', + ); + }); + test('keeps backend status messages visible without treating them as client-authored conversation', () => { const projectRecords: LocalConversationMessageRecord[] = [ { @@ -626,6 +733,54 @@ describe('Agent Runtime Provider 状态投影', () => { ).toBe('项目总控 Agent 执行失败,请稍后重试'); }); + test('展示 Codex app-server 稳定失败分类且隐藏内部诊断', () => { + const fingerprint = 'e'.repeat(64); + const contextError = + `agentLlm.code-prototype 调用 LLM 失败:` + + `kind=codex-app-server-context-window-exceeded ` + + `fingerprint=${fingerprint} chars=2048`; + const visible = projectRuntimeVisibleError( + contextError, + '程序原型 Agent', + true, + ); + expect(visible).toBe( + '程序原型 Agent 模型上下文已超限,请缩小任务范围后重试', + ); + expect(visible).not.toContain('fingerprint'); + expect(visible).not.toContain(fingerprint); + expect( + projectRuntimeVisibleError( + `kind=codex-app-server-unauthorized fingerprint=${fingerprint} chars=1`, + '项目总控 Agent', + true, + ), + ).toBe('项目总控 Agent Codex 鉴权失败,请重新登录或检查 API Key'); + }); + + test('把常见 Runtime 失败映射为可行动原因', () => { + expect( + projectRuntimeVisibleError( + '动态隔离子 Agent 缺少 expected artifact:game/index.html', + '程序 Agent', + ), + ).toBe('程序 Agent 未生成要求的产物,请查看任务要求后重试'); + expect( + projectRuntimeVisibleError('project.verify 验证失败', '程序 Agent'), + ).toBe('程序 Agent 项目验证未通过,请查看运行详情并修复后重试'); + expect( + projectRuntimeVisibleError('Agent loop 预算耗尽', '程序 Agent'), + ).toBe('程序 Agent 本轮预算已耗尽,请缩小任务范围后重试'); + expect( + projectSupervisorChatRuntimeStatus({ + ...providerRetryRuntime(), + status: 'needs-reconciliation', + phase: 'needs-reconciliation', + error: 'result-unknown,需要人工核对', + }), + ).toBe('项目总控 Agent 运行状态需要核对,请打开运行详情后重试'); + }); + test('隐藏旧失败对话与事件中的内部诊断标记', () => { const fingerprint = 'd'.repeat(64); const legacyConversation = @@ -638,6 +793,13 @@ describe('Agent Runtime Provider 状态投影', () => { expect( projectSupervisorVisibleConversationText(legacyConversation, 'user'), ).toBe(legacyConversation); + expect( + projectSupervisorVisibleConversationText( + legacyConversation, + 'assistant', + '策划 Agent', + ), + ).toBe('策划 Agent 上游服务返回 HTTP 503;自动重试已耗尽(3/3)'); const formattedEvent = formatAgentRuntimeEvent({ schemaVersion: 'game-creator-agent-runtime.v1', @@ -650,17 +812,35 @@ describe('Agent Runtime Provider 状态投影', () => { status: 'failed', phase: 'failed', summary: 'Agent Runtime 本轮处理失败。', + publicText: '项目总控 Agent 模型上下文已超限,请缩小任务范围后重试', detail: ` [redacted sensitive context] ` + `errorSha256=${fingerprint} · errorChars=99`, updatedAt: 1, }); expect(formattedEvent).toBe( - 'turn.failed · failed / failed · Agent Runtime 本轮处理失败。', + 'turn.failed · failed / failed · 项目总控 Agent 模型上下文已超限,请缩小任务范围后重试', ); expect(formattedEvent).not.toContain('errorSha256'); expect(formattedEvent).not.toContain(fingerprint); + expect( + formatAgentRuntimeEvent({ + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'project-supervisor', + taskId: 'legacy-private-detail', + sessionId: 'legacy-private-detail-session', + runId: 'legacy-private-detail-run', + source: 'project-supervisor-chat', + eventType: 'error', + status: 'failed', + phase: 'failed', + summary: 'Agent Runtime 本轮处理失败。', + detail: 'private provider body without stable diagnostics marker', + updatedAt: 1, + }), + ).toBe('error · failed / failed · Agent Runtime 本轮处理失败。'); + const emptySummaryEvent = formatAgentRuntimeEvent({ schemaVersion: 'game-creator-agent-runtime.v1', agentId: 'project-supervisor', diff --git a/apps/ai-game-creator-shell/tests/appSurface/developer-agent-window.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/developer-agent-window.suite.ts index 91a6ebc55..8e9a651ba 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/developer-agent-window.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/developer-agent-window.suite.ts @@ -1909,9 +1909,10 @@ export function registerDeveloperAgentWindowTests() { expect(screen.getByText('最近事件')).not.toBeNull(); expect( screen.getByText( - 'error · failed / failed · Agent Runtime 本轮处理失败。 · 最终回复调用失败', + 'error · failed / failed · Agent Runtime 本轮处理失败。', ), ).not.toBeNull(); + expect(screen.queryByText(/最终回复调用失败/u)).toBeNull(); expect( screen.getByText( 'observation · running / action · file.read 返回项目笔记 · 已读取 game/notes.txt', @@ -5474,9 +5475,7 @@ export function registerDeveloperToolsTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); submitChat('/audit'); @@ -5602,9 +5601,7 @@ export function registerDeveloperToolsTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); invoke.mockClear(); submitChat('/audit'); @@ -5685,9 +5682,7 @@ export function registerDeveloperToolsTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); invoke.mockClear(); submitChat('/audit'); @@ -5799,9 +5794,7 @@ export function registerDeveloperToolsTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); invoke.mockClear(); submitChat('/audit'); @@ -5897,9 +5890,7 @@ export function registerDeveloperToolsTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); submitChat('/audit'); @@ -5959,9 +5950,7 @@ export function registerDeveloperToolsTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); submitChat('/audit'); @@ -6010,9 +5999,7 @@ export function registerDeveloperToolsTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); submitChat('/audit'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index f40d96d0d..54d7e7340 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -415,9 +415,7 @@ export function registerProjectConversationTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', { projectPath: '/tmp/authorized-game', @@ -1232,9 +1230,7 @@ export function registerProjectConversationTests() { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已打开:authorized-game'), - ).not.toBeNull(); + expect(await screen.findByText('已打开:authorized-game')).not.toBeNull(); expect(await screen.findByText('正在拆解创作方向')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); @@ -1330,8 +1326,24 @@ export function registerProjectConversationTests() { agentId: 'design-director', updatedAt: 2, }); + agentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: + '后台任务失败:kind=codex-app-server-context-window-exceeded ' + + `fingerprint=${'c'.repeat(64)} chars=2048`, + agentId: 'design-director', + updatedAt: 3, + }); fireEvent.click(within(agentDialog).getByRole('button', { name: '刷新' })); expect(await screen.findByText('刷新后外部记录')).not.toBeNull(); + expect( + await screen.findByText( + '策划 Agent 模型上下文已超限,请缩小任务范围后重试', + ), + ).not.toBeNull(); + expect(agentDialog.textContent).not.toContain('fingerprint'); + expect(agentDialog.textContent).not.toContain('chars='); expect(agentConversationReadCount).toBeGreaterThanOrEqual(2); fireEvent.click(screen.getByRole('button', { name: '填入同步命令' })); expect(screen.queryByLabelText('Agent 对话')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index b5d90e54c..225618f58 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -3965,6 +3965,67 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveProperty('disabled', false); }); + it('sanitizes a persisted legacy Supervisor failure before rendering it', async () => { + const projectPath = '/tmp/launcher-legacy-failure-supervisor-game'; + const legacyFailure = + '后台任务失败:kind=codex-app-server-context-window-exceeded ' + + `fingerprint=${'a'.repeat(64)} chars=2048`; + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + supervisorMessages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: legacyFailure, + agentId: 'project-supervisor', + messageId: 'legacy-failure-message', + updatedAt: 2000, + }, + ], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-legacy-failure-supervisor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return createGameCreationAppManifest( + 'local-project-draft', + 'launcher-legacy-failure-supervisor-game', + ); + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: projectPath }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + const messageList = await screen.findByLabelText('项目总控消息'); + expect( + await within(messageList).findByText( + '项目总控 Agent 模型上下文已超限,请缩小任务范围后重试', + ), + ).not.toBeNull(); + expect(messageList.textContent).not.toContain('fingerprint'); + expect(messageList.textContent).not.toContain('chars='); + }); + it('hydrates a persisted needs-reconciliation Supervisor runtime without an active Session index', async () => { const projectPath = '/tmp/launcher-reconciliation-supervisor-game'; const manifest = createGameCreationAppManifest( @@ -4059,7 +4120,7 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorSurface = await screen.findByLabelText('项目总控对话'); expect( - await within(supervisorSurface).findByText('项目总控 Agent · 失败'), + await within(supervisorSurface).findByText('项目总控 Agent · 待核对'), ).not.toBeNull(); expect( within(supervisorSurface).getByText('当前阶段:待核对'), @@ -6335,6 +6396,29 @@ export function registerProjectSupervisorSurfaceTests() { expect(stageRecord).not.toContain('private-operation-id'); }); + it('archives a safe actionable Runtime failure instead of a generic failed stage', () => { + const runtime = gameChatRuntimeState({ + runId: 'game-chat-context-failure-run', + status: 'failed', + phase: 'failed', + error: + 'kind=codex-app-server-context-window-exceeded fingerprint=' + + 'a'.repeat(64), + updatedAt: 9200, + }); + const progress = buildGameChatProgressEvidence(runtime, {}, null); + if (!progress) { + throw new Error('missing context failure progress fixture'); + } + + const stageRecord = formatGameChatStageRecord(runtime, progress, []); + expect(stageRecord).toContain( + '项目总控 Agent 模型上下文已超限,请缩小任务范围后重试', + ); + expect(stageRecord).not.toContain('fingerprint'); + expect(stageRecord).not.toContain('本轮失败'); + }); + it('counts only the seven first-playable tasks in game-chat progress', () => { const manifest = createGameCreationAppManifest( 'game-chat-progress-total', @@ -9579,7 +9663,7 @@ export function registerProjectSupervisorSurfaceTests() { expect( within( within(dock).getByRole('article', { name: /策划 Agent/ }), - ).getByText('失败'), + ).getByText('策划 Agent 服务连接失败,请稍后重试'), ).not.toBeNull(); expect( within( @@ -9633,7 +9717,7 @@ export function registerProjectSupervisorSurfaceTests() { expect( within( within(dock).getByRole('article', { name: /策划 Agent/ }), - ).getByText('失败'), + ).getByText('策划 Agent 服务连接失败,请稍后重试'), ).not.toBeNull(); expect( within( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0f2c5497b..207f369d3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-08-12 Agent 失败原因使用稳定分类贯穿 Runtime 与正式展示面 + +- 背景:Codex app-server 的 failed turn 已携带 `turn.error.codexErrorInfo`,但适配器曾丢弃该字段并写固定失败句;Runtime、事件 `publicText`、最近任务、game-chat 阶段记录和多个 Agent 卡片又各自用固定文案覆盖已有安全原因。自主构建 final-reply 已形成确定性完成文案时还对任意错误 fallback 成成功,导致鉴权、额度、上下文、策略、sandbox、配置或网络失败可能被伪装为完成。 +- 决策:app-server 只消费协议稳定错误分类和 HTTP 状态,不公开 `message / additionalDetails`;Runtime 持久私有诊断继续脱敏,正式 conversation、失败事件 `publicText`、阶段记录、最近任务和所有 Agent 卡片统一从封闭分类派生可行动中文摘要。失败事件只允许后端 `publicText` 进入正式活动详情,缺失时使用固定安全 summary,私有 `detail` 不得展示;旧 Supervisor 与专业 Agent 失败 conversation 也必须经过同一安全映射。`needs-reconciliation` 是停止自动推进并等待人工处置的终态,显示为“待核对”,不得归为普通运行中或普通完成;未知错误保留固定安全兜底。自主构建确定性 final-reply fallback 只允许稳定 `empty-response / deserialize` 回复形状错误,任何鉴权、额度、上下文、策略、sandbox、配置、网络或上游错误都保持失败。 +- 安全边界:不得把 Provider 正文、URL/query、API Key、Token、Cookie、本地绝对路径、fingerprint、字符数或 `[redacted ...]` 占位符放入正式 UI、conversation、事件 `publicText` 或阶段记录。前端只消费后端稳定分类或已通过严格门禁的公共摘要,不从自由文本猜测敏感上游错误。 +- 未完成恢复项:isolated join 唤醒、isolated child result 发布、manifest terminal projection 和 terminal-unknown reconciliation 在持久化自身失败时仍需要独立 durable marker 与重启扫描协议;这些跨崩溃窗口必须单独设计和验证,不能用 best-effort 事件或日志冒充已恢复。 +- 验证方式:覆盖 Codex 分类与敏感诱饵、失败事件公共摘要、最近任务与各正式卡片、game-chat 阶段记录、待核对状态、final-reply fallback 白名单及 malformed 响应完整重试;运行 Rust 定向测试、前端模型/AppSurface 定向测试、Shell typecheck、编码和 diff 门禁。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-10 资源管理评审阻塞项按第二轮正式合同修复 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index baa72cddf..d2dc73a9a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -48,7 +48,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - Supervisor 持久决策与单主条件美术:game-chat 的关键词、用户是否报告“美术未接入”、占位状态和当前资产探测只形成 `advisoryOnly=true` 的补充上下文,不得直接重置 Graph、预完成美术节点、选择复用/生成分支或继承历史试玩类型。当前根 Run 没有持久化 Supervisor 决策时,scheduler 不启动任何 child;Supervisor Provider 只通过 auto-safe 的 `agent.route_manifest` 提交 `game-chat-workflow-decision.v2`:`intentSummary` 是 Supervisor 自行理解并持久化的用户意图,`strategy=audit-existing-first` 只是固定安全执行策略,两者不得混用。此动作不能审计、生成、委派或替代后续判断,也不能把整体视觉重做解释成整套美术的强制重生成;成功后 Runtime 只启动唯一 `code-prototype` 主 Agent。升级恢复时严格校验 v1 sidecar 的旧 fingerprint,并从完成合同绑定的有效任务恢复 `intentSummary`;旧 `code-director` coverage/route 只作为迁移输入,不作为当前完成证据,必须由同一根 Run 的 `code-prototype` 重新 `asset.list` 后原位替换为单主合同。确定性 `code-prototype` Run 仅兼容已知 canonical task 文本版本,其余 task/binding/root 身份继续失败关闭;升级前已运行的 fixed-graph 美术 child 不再具备任何 mutation 或生图权限。主 Agent 必须以当前正式资产、Canvas 登记、私有图集合同、四张语义切片和 art manifest 判断真实缺口;完整覆盖时直接接入,不得生成或扣费。只有可证实缺失 `art-spec` 或核心 spritesheet 时,主 Agent 才可对相应 `art-director` 或 `art-asset-plan` 建立一条 durable 委派;每次最多一个活跃美术 child,child 仅可写 `assets/**`,不得修改 `game/**` 或接入/验收游戏。若两个槽位都缺失,必须先完成 `art-director`,由同一主 Run 认领其 `EvidenceReady` delivery 后,才能委派依赖规范图的 `art-asset-plan`;失败或未就绪 delivery 不得消耗不可重试的图集委派槽位。主 Agent 认领必要回执后继续同一 Run 完成素材接入、原玩法语义校验、`game.static_smoke` 与桌面/移动 `preview.validate`。绝对硬截止对嵌套美术 child 继续核验 `root -> code-prototype -> agent-delegate` 完整身份并保留未知外部生成的 reconciliation 证据。Runtime 只负责校验根/父子身份、当前 revision、路径、Canvas 登记、缺口/路由 fingerprint、写入范围及完成证据;纯“继续”仍走既有正式 continuation 合同,普通美术措辞不得借用更老项目的具体试玩场景。不得以增加 loop 预算、伪造 revision、机械改写 manifest 或重放历史图片 action 代替 Supervisor 决策和程序侧审计。 - ready-task 对账取消续跑:未知工具结果仍停在 `needs-reconciliation` 且禁止自动重放;人工核对后显式取消原 child,保留 cancel tombstone,旧 child 和旧父 Run 按真实终态收口。若随后创建同 Session、同 Supervisor source、同有效任务语义的 continuation,新完成合同只对同时具有历史 `failed / needs-reconciliation`、最终 `cancelled` 和 durable tombstone 的 ready-task,把当前 manifest 对应 failed 节点恢复为 pending,并由 scheduler 创建全新 child Run。manifest 的读取、failed 筛选、每任务一次的 child journal 索引、证据重验和写回必须位于同一项目写锁域;较新的无 child 根 Run 只有在 durable journal 精确表明为旧 failed Graph 在进入调度前即失败时才能跨过,scheduler 自身失败必须阻断借用更老 tombstone。普通失败、无 tombstone、不同 source/Session/任务语义或证据冲突均保持失败关闭;不得复活旧 pending action、补造 observation 或把取消任务标成 completed。 - 完成门静态分析预算:Canvas 视觉门必须先做只会提前拒绝的词法预检。经典或模块脚本同时不含大小写精确的 `import` 与 `export` 字节序列时,不运行模块依赖语义分析;纯 `export ... from` / `export * from` 仍须进入正式模块图分析。当前脚本不含目标文件名或任一已绑定 DOM 图片元素 ID 时,先低成本解码 `\\xNN`、`\\uNNNN`、`\\u{...}`、简单转义和续行;解码后仍无候选才不运行完整 Canvas alias / 函数可达性分析,解码不确定则保守进入 Oxc。存在任一候选时仍执行原 parser、semantic binding、解码后的 computed 属性/StringLiteral 路径、可达 `drawImage`、可见 Canvas、路径大小写和动态 namespace 写入门禁;HTML 中存在某个绑定元素不得使所有无关 JavaScript 单元进入重分析,禁止把词法命中当作通过条件。 -- Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。 +- Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”。`codex_app_server` 收到 failed turn 时必须读取协议 `turn.error.codexErrorInfo`,按上下文超限、会话预算、用量、鉴权、请求、策略、sandbox、会话恢复和连接 / HTTP 状态生成封闭稳定分类;不得丢弃该字段后统一写“turn 执行失败”,也不得把 `message / additionalDetails` 原文公开。自主构建已有本地确定性完成文案时,也只允许 `empty-response / deserialize` 这类回复形状错误使用 fallback;鉴权、额度、上下文、策略、sandbox、配置、网络和上游错误必须保持失败,禁止用完成文案掩盖。正式面、阶段记录、Runtime 活动详情和持久 conversation 从稳定分类派生同一份可行动中文摘要;失败事件活动详情只消费后端 `publicText`,缺失时退回固定安全 summary,禁止公开私有 `detail`。旧 Supervisor 与专业 Agent 失败 conversation 必须在展示时经过相同安全映射。`needs-reconciliation` 是停止自动推进、轮询和活跃计数并等待人工处置的终态,明确显示为待核对,不能显示为普通运行中或已完成;未知分类仍使用固定安全兜底。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。 - 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待唯一 `code-prototype` 主 Run 及其所有必要美术委派都已形成真实终态,再把本轮、主 Agent 进度、是否复用/补齐素材、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 child 或 manifest 仍在 hydration 时不得以陈旧快照提前归档,要暂存终态 Runtime 并在状态刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 - 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。 - Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`。 @@ -197,7 +197,7 @@ V1.47 在只读工具边界和 batch v3/v2/v1 恢复终审修复后的最新独 V1.17 计划快照随 `game-creator-runtime-context-bundle.v3` 持久化,v2 在通过原身份、revision 和 verification gate 校验后从当前 Runtime state 补齐计划字段继续恢复;计划元数据本身不推进项目 revision、不改变 verification gate,也不触发项目权限确认。开发 UI 和 CLI 有界展示 revision、说明与完整 8 步;正式用户的 Supervisor 只展示完成数、当前步骤、等待对象、下一步和协作数量的紧凑摘要。恢复、same-run steer 和真实 Provider 的完整验收矩阵以 Runtime V1.17 章节为准;2026-07-16 已在当前 v5 context 上完成正式 `openai_chat / gpt-5.5` 的同 run steer + Runner 强杀恢复专项,门禁状态为 PASS。 -2026-07-18 起,正式项目工作台的总控与策划 / 美术 / 程序 Agent 状态统一投影当前 Supervisor 父 run 的真实 Runtime;专业 Agent 只有在 `parentRunId` 精确匹配该父 run 时才可进入当前项目状态列表。普通项目页在 Tauri event 之外必须保留只读轮询,兜底独立 Runner 无法可靠投递 App event 的情况;短暂读取失败时保留最后一份可信快照,不得清空或倒退界面状态。正式面只展示真实运行阶段、计划完成数 / 总数、最近更新时间、失败、待确认与待回答等紧凑状态;专业 Agent 的确认或拒绝必须同时绑定真实 `agentId + runId + actionId`。`manifest.tasks` 只能在没有匹配 Runtime 时作为回退,不得覆盖真实 Runtime;正式面不展示内部 `currentAction`、`observation`、工具计划正文、Provider 错误原文、fingerprint 或字符计数,transport / timeout / 鉴权 / 限流等失败只映射为可理解的安全文案,也不得根据 manifest 或动画伪造生产中、进度百分比或完成状态。当前父 run 或专业状态集合变化时 Runtime 状态区回到顶部,总控摘要在内部滚动期间保持可见。 +2026-07-18 起,正式项目工作台的总控与策划 / 美术 / 程序 Agent 状态统一投影当前 Supervisor 父 run 的真实 Runtime;专业 Agent 只有在 `parentRunId` 精确匹配该父 run 时才可进入当前项目状态列表。普通项目页在 Tauri event 之外必须保留只读轮询,兜底独立 Runner 无法可靠投递 App event 的情况;短暂读取失败时保留最后一份可信快照,不得清空或倒退界面状态。正式面只展示真实运行阶段、计划完成数 / 总数、最近更新时间、失败、待确认与待回答等紧凑状态;专业 Agent 的确认或拒绝必须同时绑定真实 `agentId + runId + actionId`。`manifest.tasks` 只能在没有匹配 Runtime 时作为回退,不得覆盖真实 Runtime;正式面不展示内部 `currentAction`、`observation`、工具计划正文、Provider 错误原文、fingerprint 或字符计数。transport / timeout / 鉴权 / 限流、Codex 稳定错误分类,以及验证、预期产物、权限策略、预算、恢复对账和持久化等常见 Runtime 失败必须映射为可理解、可行动的安全文案;底部子 Agent 状态卡在失败时直接展示同一安全摘要,不能只写“失败”或“子 Agent 任务失败”。不得根据 manifest 或动画伪造生产中、进度百分比或完成状态。当前父 run 或专业状态集合变化时 Runtime 状态区回到顶部,总控摘要在内部滚动期间保持可见。 2026-07-19 起,当前父 run 下的专业 Agent 进入 `failed` 后,正式工作台必须提供“在当前项目重试”恢复入口,不得要求用户新建项目。重试必须精确核对原 `agentId + runId + parentRunId`,复用原 task、active Session 和父 run 归属,同时生成新的专业 Agent runId;新 run 继承已持久化的上下文和父子绑定,不覆写旧失败 run 的审计事实,也不得把 UI 重试解释为底层 transport 根因已修复。`agent.resume` 默认 `confirm` 不变:自动 retry command 继续执行 auto gate;正式失败卡按钮自身是本次明确确认,使用 deny-only 的 confirmed retry command。按钮必须原卡即时显示“正在提交重试”、受理或安全错误;若 Supervisor 已为同一 delegation 准备合同 repair,则该按钮优先确认既有 repair,避免重复派发。