diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs index eadb1f818..4e1f0e536 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_final_reply.rs @@ -7,6 +7,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ run_id: &str, task: &str, plan: &AgentRuntimeToolPlan, + fallback_response: Option<&str>, observations: &[AgentRuntimeToolObservation], applied_steer_cursor: u64, request_slot: &str, @@ -116,12 +117,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_final_reply_ let stream_snapshot = provider_snapshot.clone(); let suppress_private_process_output = agent_runtime_observations_contain_private_process_output(observations); - let fallback_response = (!plan.response.trim().is_empty()) - .then(|| strip_llm_thinking_blocks(&plan.response)) - .map(|response| { - redact_agent_runtime_private_process_output_from_response(&response, observations) - }) - .filter(|response| !response.trim().is_empty()); + let fallback_response = fallback_response.filter(|response| !response.trim().is_empty()); let stream_response = llm.stream; let request_stream_snapshot = stream_snapshot.clone(); let response_result = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index ffbf8bec5..c91702a39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -192,6 +192,8 @@ mod finalization; mod interaction; mod lifecycle_control; mod main_loop; +#[cfg(test)] +mod main_loop_tests; mod pending_execution; mod pending_recovery; mod provider_recovery; 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 f2d36ee6e..a94ae587d 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 @@ -1,5 +1,29 @@ use super::*; +pub(super) fn game_creator_agent_background_final_reply_fallback( + plan_response: &str, + run_profile: &str, + agent_id: &str, + response_revision: u64, +) -> Option { + if !plan_response.trim().is_empty() { + let response = strip_llm_thinking_blocks(plan_response); + return (!response.trim().is_empty()).then_some(response); + } + (run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .then(|| { + format!( + "项目已完成生成,并通过当前 revision {response_revision} 的静态检查和桌面、移动端交互试玩验证。" + ) + }) +} + +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_TOOL_PLAN: &str = "tool-plan-failed"; +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhausted"; +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed"; +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed"; + pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, @@ -505,17 +529,10 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c }, ); if let Ok(runtime) = failed_runtime { - let _ = append_agent_db_record( + let _ = append_game_creator_agent_background_task_failed_audit( &root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.failed", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "source": runtime.source, - "error": runtime.error, - }), + &runtime, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_TOOL_PLAN, ); } return AgentBackgroundTaskOutcome::Finished; @@ -2596,18 +2613,10 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c }, ); if let Ok(runtime) = failed_runtime { - let _ = append_agent_db_record( + let _ = append_game_creator_agent_background_task_failed_audit( &root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.failed", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "source": runtime.source, - "failureKind": "loop-budget-exhausted", - "error": runtime.error, - }), + &runtime, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET, ); } return AgentBackgroundTaskOutcome::Finished; @@ -2664,6 +2673,16 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c ); let response_next_loop_index = usize::try_from(runtime.loop_iteration).unwrap_or(usize::MAX); + let final_reply_fallback = game_creator_agent_background_final_reply_fallback( + &plan.response, + &runtime.run_profile, + &agent_id, + response_revision, + ) + .map(|response| { + redact_agent_runtime_private_process_output_from_response(&response, &observations) + }) + .filter(|response| !response.trim().is_empty()); if let Err(error) = persist_game_creator_agent_runtime_context( &root, &runtime, @@ -2688,6 +2707,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c &runtime.run_id, &task, &plan, + final_reply_fallback.as_deref(), &observations, runtime.applied_steer_cursor, &final_reply_request_slot, @@ -2730,10 +2750,6 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c ); } } - let plan_fallback_response = redact_agent_runtime_private_process_output_from_response( - &strip_llm_thinking_blocks(&plan.response), - &observations, - ); let reply = match final_reply_result { Ok(RequestedAgentRuntimeFinalReplyOutcome::Ready(Some(requested_reply))) => { runtime.context_usage.estimated_input_tokens = @@ -2879,7 +2895,9 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c { return AgentBackgroundTaskOutcome::NeedsReconciliation; } - Err(_) if !plan_fallback_response.trim().is_empty() => plan_fallback_response, + Err(_) if final_reply_fallback.is_some() => { + final_reply_fallback.expect("checked final reply fallback") + } Err(error) => { let error = redact_agent_runtime_error(&root, &error, 500); let failed_runtime = @@ -2895,17 +2913,10 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c }, ); if let Ok(runtime) = failed_runtime { - let _ = append_agent_db_record( + let _ = append_game_creator_agent_background_task_failed_audit( &root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.failed", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "source": runtime.source, - "error": runtime.error, - }), + &runtime, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY, ); } return AgentBackgroundTaskOutcome::Finished; @@ -2976,17 +2987,10 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c Err(error) => { let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); if let Ok(runtime) = failed_runtime { - let _ = append_agent_db_record( + let _ = append_game_creator_agent_background_task_failed_audit( &root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.failed", - "agentId": runtime.agent_id, - "taskId": runtime.task_id, - "sessionId": runtime.session_id, - "runId": runtime.run_id, - "source": runtime.source, - "error": runtime.error, - }), + &runtime, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION, ); } AgentBackgroundTaskOutcome::Finished 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 new file mode 100644 index 000000000..8ba28aea4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -0,0 +1,381 @@ +use super::*; + +fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState) -> u64 { + let contract = read_autonomous_completion_contract(root, &state.agent_id, &state.run_id) + .expect("read autonomous completion contract") + .expect("autonomous completion contract exists"); + let revision = { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "test.autonomous.final_reply.mutate", + ) + .expect("acquire autonomous mutation lock"); + let revision = prepare_agent_runtime_project_mutation_locked( + root, + &state.agent_id, + &state.run_id, + "file.write", + ) + .expect("advance autonomous project revision"); + write_local_project_file_at( + root, + AGENT_RUNTIME_GAME_INDEX_PATH, + "可玩塔防", + ) + .expect("write autonomous game index"); + revision + }; + { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "test.autonomous.final_reply.verify", + ) + .expect("acquire autonomous verification lock"); + let (revision, gate) = begin_agent_runtime_project_verification_locked( + root, + &state.agent_id, + &state.run_id, + "game.static_smoke", + ) + .expect("begin autonomous static smoke"); + finish_agent_runtime_project_verification_locked(root, &revision, gate, true) + .expect("finish autonomous static smoke"); + } + bind_supervisor_collaboration_policy_snapshot_at( + root, + &state.agent_id, + &state.run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind empty collaboration policy"); + + let evidence_root = root + .join(".agent/runtime/browser-validations") + .join(agent_runtime_confirmation_path_component( + &state.agent_id, + "agent", + )) + .join(agent_runtime_confirmation_path_component( + &state.run_id, + "run", + )) + .join(revision.to_string()); + fs::create_dir_all(&evidence_root).expect("create browser evidence root"); + let screenshots = [ + evidence_root.join("desktop.png"), + evidence_root.join("mobile.png"), + ]; + for screenshot in &screenshots { + fs::write(screenshot, b"\x89PNG\r\n\x1a\nfixture") + .expect("write browser screenshot fixture"); + } + let viewport = |viewport, screenshot_path| BrowserViewportValidationResult { + viewport, + width: if viewport == BrowserValidationViewport::Desktop { + 1280 + } else { + 390 + }, + height: if viewport == BrowserValidationViewport::Desktop { + 720 + } else { + 844 + }, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "自主试玩测试".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: "可试玩项目".to_string(), + visible_text_character_count: 5, + dom_character_count: 100, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: Vec::new(), + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path, + passed: true, + diagnostics: Vec::new(), + }; + let scenario = BrowserPlaytestScenario::LaneDefenseV1; + let result = BrowserValidationResult { + schema_version: "browser-validation.v1".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "test-browser".to_string(), + protocol_version: "1".to_string(), + }, + passed: true, + viewport_results: vec![ + viewport(BrowserValidationViewport::Desktop, screenshots[0].clone()), + viewport(BrowserValidationViewport::Mobile, screenshots[1].clone()), + ], + playtest: Some(BrowserPlaytestResult { + scenario, + scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario), + passed: true, + initial_sequence: Some(1), + initial_phase: Some(BrowserPlaytestPhase::Ready), + initial_level: Some(1), + final_sequence: Some(8), + final_phase: Some(BrowserPlaytestPhase::Playing), + final_level: Some(2), + assertions: vec![BrowserPlaytestAssertion { + name: "fixture-passed".to_string(), + passed: true, + }], + diagnostics: Vec::new(), + }), + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: evidence_root.clone(), + report_path: evidence_root.join("validation.json"), + }, + completed_at_unix_ms: 1, + }; + fs::write( + &result.evidence.report_path, + serde_json::to_vec_pretty(&result).expect("serialize browser evidence"), + ) + .expect("write browser evidence report"); + let action = AgentRuntimeToolAction { + tool: "preview.validate".to_string(), + reason: Some("验证真实可玩闭环".to_string()), + input: serde_json::json!({}), + }; + let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); + let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint); + write_autonomous_playtest_receipt_at( + root, + &contract, + &action_id, + &action_fingerprint, + revision, + &result, + ) + .expect("write autonomous playtest receipt"); + assert!(autonomous_game_build_completion_blocker_at_locked(root, state).is_none()); + revision +} + +#[test] +fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() { + assert_eq!( + game_creator_agent_background_final_reply_fallback( + "", + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 7, + ) + .as_deref(), + Some("项目已完成生成,并通过当前 revision 7 的静态检查和桌面、移动端交互试玩验证。") + ); +} + +#[test] +fn ordinary_agent_empty_plan_has_no_deterministic_final_reply_fallback() { + assert!(game_creator_agent_background_final_reply_fallback( + "", + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + "code-prototype", + 7, + ) + .is_none()); +} + +#[test] +fn standard_supervisor_empty_plan_has_no_deterministic_final_reply_fallback() { + assert!(game_creator_agent_background_final_reply_fallback( + "", + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 7, + ) + .is_none()); +} + +#[test] +fn plan_response_precedes_autonomous_supervisor_deterministic_fallback() { + assert_eq!( + game_creator_agent_background_final_reply_fallback( + "沿用现有计划回复。", + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + 7, + ) + .as_deref(), + Some("沿用现有计划回复。") + ); +} + +#[tokio::test] +async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallback_once() { + const RUN_ID: &str = "autonomous-final-reply-fallback-run"; + const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏"; + const TEST_KEY: &str = "autonomous-final-reply-fallback-key"; + + let temporary = tempfile::tempdir().expect("create autonomous fallback root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "autonomous-fallback-project", TASK) + .expect("init autonomous fallback project"); + let planning_response = serde_json::json!({ + "thinkingSummary": "当前 revision 的完成证据已经齐全", + "planUpdate": null, + "plan": [], + "actions": [], + "response": "" + }) + .to_string(); + let base_url = + crate::tests::spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response); + let _config_guard = crate::tests::write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "project-supervisor": {{ + "apiKey": "{TEST_KEY}", + "baseUrl": {base_url:?}, + "model": "autonomous-fallback-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0, + "retryBackoffMs": 1 + }} + }} +}}"# + )); + let lane_lock = try_acquire_game_creator_agent_runtime_task_lock( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + ) + .expect("acquire Supervisor lane") + .expect("Supervisor lane available"); + start_game_creator_supervisor_background_task_for_session_at( + &root, + None, + TASK, + RUN_ID, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect("queue autonomous Supervisor task"); + let task_record = read_latest_game_creator_agent_runtime_task_by_run_id( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("read queued Supervisor task") + .expect("queued Supervisor task exists"); + let state = agent_runtime_state_from_task_record(&task_record); + let revision = prepare_autonomous_completion_evidence(&root, &state); + drop(lane_lock); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume autonomous Supervisor task"); + let mut runtime = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read autonomous Supervisor runtime") + .state; + for _ in 0..500 { + if runtime.status == "idle" || runtime.status == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("poll autonomous Supervisor runtime") + .state; + } + let fallback = format!( + "项目已完成生成,并通过当前 revision {revision} 的静态检查和桌面、移动端交互试玩验证。" + ); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.last_response.as_deref(), Some(fallback.as_str())); + + let conversation = read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + Some(&task_record.session_id), + ) + .expect("read autonomous Supervisor conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![fallback.as_str()] + ); + let stream = read_game_creator_agent_runtime_response_stream_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("read autonomous fallback response stream") + .expect("autonomous fallback response stream exists"); + assert_eq!(stream.status, "committed"); + assert_eq!(stream.accumulated_text, fallback); + assert!(read_game_creator_agent_runtime_finalization_journal( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("read finalization residue") + .is_none()); + assert!(provider_retry::read_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("read retry residue") + .is_none()); + assert!(provider_handoff::read_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("read handoff residue") + .is_none()); + assert!(tool_plan_handoff::read_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("read tool-plan handoff residue") + .is_none()); + + let public_paths = [ + root.join(".agent/agent.db"), + game_creator_agent_runtime_event_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + root.join(".agent/activity.jsonl"), + root.join(".agent/output.jsonl"), + ]; + let project_path = root.to_string_lossy().into_owned(); + for path in public_paths.iter().filter(|path| path.exists()) { + let content = fs::read_to_string(path).expect("read autonomous fallback public audit"); + for forbidden in [fallback.as_str(), TEST_KEY, project_path.as_str()] { + assert!(!content.contains(forbidden)); + } + } + let public_records = + fs::read_to_string(&public_paths[0]).expect("read autonomous fallback audit"); + let final_reply_lifecycle_statuses = public_records + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == RUN_ID + && record["requestKind"] == "final-reply" + }) + .filter_map(|record| record["status"].as_str().map(str::to_string)) + .collect::>(); + assert_eq!(final_reply_lifecycle_statuses, vec!["started", "failed"]); +} 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 a362fa71f..b2d14b496 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 @@ -1061,6 +1061,44 @@ pub(crate) fn finish_game_creator_agent_background_runtime_turn_at( ) } +fn game_creator_agent_runtime_failure_metadata(error: &str) -> (String, usize) { + ( + format!("{:x}", Sha256::digest(error.as_bytes())), + error.chars().count(), + ) +} + +fn game_creator_agent_runtime_public_failure_detail(error: &str) -> String { + let (error_sha256, error_chars) = game_creator_agent_runtime_failure_metadata(error); + format!("errorSha256={error_sha256} · errorChars={error_chars}") +} + +pub(crate) fn append_game_creator_agent_background_task_failed_audit( + root: &Path, + state: &AgentRuntimeState, + failure_kind: &'static str, +) -> Result<(), String> { + let error = state + .error + .as_deref() + .ok_or_else(|| "后台任务失败审计缺少私有错误诊断".to_string())?; + let (error_sha256, error_chars) = game_creator_agent_runtime_failure_metadata(error); + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.failed", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "failureKind": failure_kind, + "errorSha256": error_sha256, + "errorChars": error_chars, + }), + ) +} + pub(crate) fn fail_game_creator_agent_runtime_turn_at( root: &Path, mut state: AgentRuntimeState, @@ -2278,6 +2316,12 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( || summary.starts_with("command.output_read:"))) }) .map(|value| { + if matches!( + event_type, + "error" | "turn.failed" | "turn.budget_exhausted" + ) { + return game_creator_agent_runtime_public_failure_detail(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 941aed414..1ee1c6337 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 @@ -19,7 +19,7 @@ fn valid_test_png_bytes() -> Vec { .expect("valid 1x1 test png") } -struct TestConfigGuard { +pub(crate) struct TestConfigGuard { _lock: StdMutexGuard<'static, ()>, path: PathBuf, previous: Option>, @@ -702,7 +702,7 @@ fn replace_test_local_config(path: &Path, content: impl AsRef<[u8]>) { fs::rename(&temp_path, path).expect("replace local config"); } -fn write_test_local_config(content: String) -> TestConfigGuard { +pub(crate) fn write_test_local_config(content: String) -> TestConfigGuard { let lock = TEST_CONFIG_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -1079,6 +1079,46 @@ fn spawn_mock_llm_server_responses(response_contents: Vec) -> String { spawn_mock_llm_server_responses_with_capture(response_contents, None) } +pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( + planning_response: String, +) -> String { + let listener = bind_test_tcp_listener("mock invalid final reply bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + let (mut planning_stream, _) = listener.accept().expect("mock tool plan accept"); + drop(read_mock_http_request(&mut planning_stream)); + let planning_body = serde_json::json!({ + "id": "resp_invalid_final_reply_planning", + "model": "mock-game-model", + "output_text": planning_response, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(); + let planning_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + planning_body.len(), + planning_body + ); + planning_stream + .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"); + }); + base_url +} + fn final_tool_plan_response(response: impl Into) -> String { serde_json::json!({ "thinkingSummary": "已有工具观察足够,可以收束后台任务", 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 b8dd519dc..46b5d23c4 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 @@ -541,6 +541,280 @@ fn structured_plan_failure_after_all_steps_completed_preserves_terminal_snapshot fs::remove_dir_all(root).ok(); } +#[test] +fn runtime_failure_public_audits_hash_private_delivery_diagnostics() { + for ( + agent_id, + child_run_id, + parent_run_id, + parent_action_id, + private_error, + budget_exhausted, + terminal_event_type, + failure_kind, + ) in [ + ( + "code-prototype", + "public-failure-audit-code-run", + "public-failure-audit-code-parent-run", + "action-111111111111111111111111", + "private deliveryStructuredResult:程序 Agent 失败诊断正文仅供私有返工使用", + false, + "turn.failed", + "tool-plan-failed", + ), + ( + "quality-review", + "public-failure-audit-quality-run", + "public-failure-audit-quality-parent-run", + "action-222222222222222222222222", + "private deliveryResult:质量 Agent 预算耗尽诊断正文仅供私有返工使用", + true, + "turn.budget_exhausted", + "loop-budget-exhausted", + ), + ] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "公共失败审计正文隔离测试") + .expect("project init"); + let parent = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "等待专业 Agent 私有失败回执", + parent_run_id, + "agent-chat", + "等待专业 Agent", + vec!["处理专业 Agent 回执".to_string()], + ) + .expect("start parent runtime"); + let mut child = start_game_creator_agent_runtime_task_at( + &root, + agent_id, + "执行专业 Agent 任务并保留私有失败诊断", + child_run_id, + "agent-delegate", + "执行专业任务", + vec!["返回私有诊断".to_string()], + ) + .expect("start child runtime"); + let delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + agent_id, + parent_action_id, + ); + child.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + child.parent_run_id = Some(parent_run_id.to_string()); + child.delegation_id = Some(delegation_id.clone()); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent.session_id, + parent_run_id, + parent_action_id, + &delegation_id, + agent_id, + &child.session_id, + child_run_id, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create static delivery"); + + let failed = if budget_exhausted { + fail_game_creator_agent_runtime_budget_at(&root, child, private_error) + } else { + fail_game_creator_agent_runtime_turn_at(&root, child, private_error) + } + .expect("persist private failure state"); + append_game_creator_agent_background_task_failed_audit(&root, &failed, failure_kind) + .expect("append public failure audit"); + + assert_eq!(failed.error.as_deref(), Some(private_error)); + let private_delivery = read_static_delegate_delivery_at(&root, &delegation_id) + .expect("read private delivery") + .expect("private delivery exists"); + assert_eq!(private_delivery.status, StaticDelegateDeliveryStatus::Ready); + assert_eq!( + private_delivery.result_summary.as_deref(), + Some(private_error) + ); + assert_eq!( + private_delivery + .structured_result + .as_ref() + .and_then(|result| result.error.as_deref()), + Some(private_error) + ); + + let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes())); + let error_chars = private_error.chars().count(); + let expected_public_detail = + format!("errorSha256={error_sha256} · errorChars={error_chars}"); + let result = read_game_creator_agent_runtime_at(&root, agent_id) + .expect("read failed runtime projection"); + let public_failure_events = result + .recent_events + .iter() + .filter(|event| { + event.run_id == child_run_id + && matches!( + event.event_type.as_str(), + "error" | "turn.failed" | "turn.budget_exhausted" + ) + }) + .collect::>(); + assert_eq!(public_failure_events.len(), 2); + assert!(public_failure_events + .iter() + .any(|event| event.event_type == "error")); + assert!(public_failure_events + .iter() + .any(|event| event.event_type == terminal_event_type)); + assert!(public_failure_events.iter().all(|event| { + event.detail.as_deref() == Some(expected_public_detail.as_str()) + && !event.summary.contains(private_error) + })); + let event_log = fs::read_to_string(game_creator_agent_runtime_event_path(&root, agent_id)) + .expect("read public event log"); + assert!(!event_log.contains(private_error)); + + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); + assert!(!agent_db.contains(private_error)); + let failure_records = agent_db + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.failed") + && record.get("runId").and_then(Value::as_str) == Some(child_run_id) + }) + .collect::>(); + assert_eq!(failure_records.len(), 1); + let failure_record = &failure_records[0]; + assert_eq!(failure_record["agentId"], agent_id); + assert_eq!(failure_record["taskId"], agent_id); + assert_eq!(failure_record["sessionId"], failed.session_id); + assert_eq!(failure_record["runId"], child_run_id); + assert_eq!(failure_record["source"], "agent-delegate"); + assert_eq!(failure_record["failureKind"], failure_kind); + assert_eq!(failure_record["errorSha256"], error_sha256); + assert_eq!( + failure_record["errorChars"].as_u64(), + Some(error_chars as u64) + ); + assert!(failure_record.get("error").is_none()); + + fs::remove_dir_all(root).ok(); + } +} + +#[tokio::test] +async fn background_final_reply_failure_keeps_private_conversation_and_hashes_public_audits() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "最终回复公共失败审计测试") + .expect("project init"); + let planning_response = serde_json::json!({ + "thinkingSummary": "已有上下文足够,准备生成最终回复", + "plan": ["回复开发者"], + "actions": [], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses", + "maxRetries": 0 + }} + }} +}}"# + )); + let run_id = "background-final-reply-public-failure-audit-run"; + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证最终回复失败的私有诊断和公共审计边界", + run_id, + ) + .expect("start background task"); + + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read initial runtime") + .state; + for _ in 0..250 { + if runtime.status == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read failed runtime") + .state; + } + assert_eq!(runtime.status, "failed"); + assert_eq!(runtime.phase, "failed"); + let private_error = runtime.error.clone().expect("private runtime error"); + assert!(!private_error.trim().is_empty()); + + let error_sha256 = format!("{:x}", Sha256::digest(private_error.as_bytes())); + let error_chars = private_error.chars().count(); + let expected_public_detail = format!("errorSha256={error_sha256} · errorChars={error_chars}"); + let result = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read public failure projections"); + for event_type in ["error", "turn.failed"] { + let event = result + .recent_events + .iter() + .find(|event| event.run_id == run_id && event.event_type == event_type) + .expect("public failure event"); + assert_eq!( + event.detail.as_deref(), + Some(expected_public_detail.as_str()) + ); + } + let event_log = fs::read_to_string(game_creator_agent_runtime_event_path( + &root, + "design-director", + )) + .expect("read public event log"); + assert!(!event_log.contains(&private_error)); + + let failure_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.failed") + && record.get("runId").and_then(Value::as_str) == Some(run_id) + }) + .collect::>(); + assert_eq!(failure_records.len(), 1); + let failure_record = &failure_records[0]; + assert_eq!(failure_record["failureKind"], "final-reply-failed"); + assert_eq!(failure_record["errorSha256"], error_sha256); + assert_eq!( + failure_record["errorChars"].as_u64(), + Some(error_chars as u64) + ); + assert!(failure_record.get("error").is_none()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); + assert!(!agent_db.contains(&private_error)); + + let conversation = read_local_conversation_for_session_at( + &root, + Some("design-director"), + Some(&runtime.session_id), + ) + .expect("read private failure conversation"); + assert!(conversation.messages.iter().any(|message| { + message.role == "assistant" && message.content == format!("后台任务失败:{private_error}") + })); + + fs::remove_dir_all(root).ok(); +} + #[test] fn structured_plan_failure_and_budget_preserve_last_trusted_progress() { for budget_exhausted in [false, true] { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 14fdbfc9f..31d7c048c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5234,3 +5234,21 @@ - 后续收口:`App.tsx` 从 `12983` 行降到 `9794` 行,项目工作区下沉到 `src/features/project-workspace/` 的 11 个模块;`runtime_actions.rs` 从 `12519` 行降到 `139` 行,拆为 19 个生产模块和 2 个测试模块;`runtime_driver.rs` 从 `10510` 行降到 `394` 行并拆为 11 个模块;`runtime_protocol.rs` 从 `8623` 行降到 `105` 行并拆为 14 个模块,单模块不超过 `1319` 行。`runtime_driver/main_loop.rs` 仍约 `2995` 行,因为它承载现有单一主循环函数;后续必须先按运行状态阶段建立边界再拆分,不能继续机械切割。 - Rust 可见性与兼容性:嵌套子模块会改变 `pub(super)` 的直接父级语义。`runtime_tools` 中原本要供 `crate::agent` 兄弟模块使用的符号最小化调整为 `pub(in crate::agent)`;同一 facade 下跨子模块 helper 保持直接父级 `pub(super)`,`runner` 和 `process_session` 的内部兄弟调用仍经父模块 facade,不扩大到 crate 公共 API。新增 `runtime_protocol::provider_retry` 后,访问 crate 根同名模块必须写为 `crate::provider_retry`;原 facade 的兼容重导出继续保留,编译器仅因当前文件未直接消费而告警时使用局部 `#[allow(unused_imports)]`,不得机械删除。 - 验收:稳定共享树的客户端 `cargo fmt --check`、typecheck、Prettier、ESLint 和编码检查通过;Rust 串行全量 `1139 passed / 5 ignored / 0 failed`,客户端前端 `329/329` 通过。确定性 E2E self-test 与完整 E2E 均通过;完整链路继续满足 17 次 Provider lifecycle、revision `0 -> 2`、真实浏览器 `37/37`,重复、残留和泄漏均为 `0`。 + +## 2026-07-22 AI 游戏创作自主构建完成后由 Supervisor 确定性收束回复 + +- 背景:最新真实外部 E2E 已推进到 revision 5,最终浏览器试玩 `37/37`、Supervisor 计划 `8/8`;`image.inspect` 视觉请求与最终回复却先后命中同一 deserialize fingerprint。终局 `114` 个 Provider request identity 中 `113 completed / 1 final-reply failed`,因没有 Supervisor assistant,整轮仍是 **FAIL**,不得记为外部 Provider 全链路 PASS。 +- 决策:确定性最终回复只适用于 `autonomous-game-build` profile、规范 Agent `project-supervisor`,并且当前 revision 的 completion gates 已全部通过之后发生的 `final-reply` 收束。优先使用非空 `plan.response`;只有它为空时,才生成“当前 revision 已完成生成并通过静态、桌面和移动试玩”的确定性回复。 +- 失败关闭:普通 Agent、尚未收敛的自主构建、任一完成门禁未通过或存在 reconciliation 时,继续沿用原失败路径,不得生成成功回复。该兜底不放宽工具、协作、验证、试玩或恢复门禁,也不从中间 planning 或失败 observation 推断项目已完成。 +- 证据边界:Provider lifecycle 必须保留真实 final-reply failed identity、fingerprint 和终态,不得为了写 assistant 把失败请求改成 completed、隐藏或重编号。兜底只解决已完成项目缺少用户收束的问题,不能成为 Provider 成功证据。 +- 验证:代码修复完成后必须重新运行独立真实外部 E2E,并在同一轮核对当前 revision、completion gates、唯一 Supervisor assistant、Provider lifecycle、残留、重复与泄漏。新一轮完整通过前,外部 Provider 全链路状态继续记为未 PASS。 +- 最新真实轮次:新 fallback 已命中,父 Supervisor 终局为 `idle / completed`,`turn.report` 为 `settled`,只产生 `1` 条 `44` 字符的 Supervisor assistant;pending、retry、handoff、finalization、reconciliation、重复、API Key 和路径泄漏均为 `0`。因此“完成后不回复”已在该轮解决。 +- 轮次结论:该轮仍是 **FAIL**,不能记为 PASS。`105` 个 Provider identity 中 `103 completed / 2 failed`;两个原始专业 Agent 失败均已由 repair 恢复,但最终验收命中 `supervisor-swarm-private-body-public-event-leak`。 +- 脱敏定位:两个专业 Agent 的失败正文分别为 `149 / 123` 字符,对应 SHA-256 前缀 `494ce8 / 3089ad`,共进入 `4` 条 `event.detail` 和 `2` 条 `agent.runtime.background_task.failed.error`。六处内容均属于 delivery result,不是 userTask、委派任务或对话正文,与 final-reply fallback 无直接关系。 +- 修复原则:私有 `state.error` 和私有 delivery 保留诊断正文;公共 event 与 agentDb 只写 `errorSha256 / errorChars /` 稳定 `failureKind`。不得依赖正文黑名单,也不得为通过验收把真实失败改写为成功。 +- 后续验收:完成上述公共投影脱敏后,必须另起一轮独立真实外部 E2E;在该轮完整通过前,当前外部 Provider 全链路状态仍为未 PASS。 +- 最终独立真实外部轮次:公共投影脱敏修复后另起的新轮次独立取得完整证据,`status=PASS`、`evidence=complete`、`privacy scan=complete`。上述 `114` identity 与 `105` identity 两个 **FAIL** 继续保留为独立历史失败,不与本轮拼接;最终 PASS 是单个新轮次的完整证据,当前外部 Provider 全链路状态据此更新为 **PASS**。 +- Provider 与任务终态:本轮共有 `84` 个 Provider identity,`started / terminal / completed` 均为 `84`,`failed / retry / open / duplicate` 均为 `0`。`1` 个原专业任务以 `budget-exhausted` 终止,唯一 repair 已 `completed` 并标记 `recovered`;最终 child 为 `2 completed + 1 historical failed`,所有任务均处于终态。 +- 父级收束:父 Supervisor 为 `idle / completed`,`turn.report` 为 `settled`;唯一 Supervisor assistant 为 `297` 字符,`completed audit=1`,finalization 完成 `4` 个 stages。 +- 项目与试玩:revision 从 `0 -> 4`,`game/index.html` 为 `7639` bytes 且内容已变化,`game.static_smoke` passed;`lane-defense-v1` 的 desktop / mobile 浏览器验证均通过,固定试玩为 `37/37`。 +- 零值、隐私与清理:pending / confirmation / user-input / provider batch / retry / handoff / tool-plan handoff / finalization 残留 / reconciliation / duplicate 全为 `0`;Provider payload / private body / API Key / project path / config path / log / browser report leak 全为 `0`;人工 approve / answer / steer 全为 `0`。Runner 与 AppData 已清理,项目因 `--keep-project` 暂留后由主线程清理。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 087fd661a..b5b971610 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3512,3 +3512,19 @@ - 原因:多个 Agent 虽然拥有互不重叠的写入文件,但全 crate 编译会同时读取所有模块。某个入口刚写入 `mod`、对应子文件尚未全部落盘时启动构建,会读到合法的中间态半成品。 - 处理:并行 Agent 只做各自 scoped 格式和测试;主 Agent 等所有写入方正式完成并关闭后,再在稳定共享树统一运行 crate fmt、全量测试和真实 E2E。编译前失败且 `stdin/provider/task=0` 的轮次只能算 harness 准备失败,不能归因给 Runtime 行为。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/`。 + +## 确定性最终回复不能掩盖外部 Provider 的真实失败 + +- 现象:自主构建已经推进到 revision 5,浏览器试玩 `37/37`、Supervisor 计划 `8/8`,但 `image.inspect` 视觉请求与最终回复命中同一 deserialize fingerprint;`114` 个 Provider request identity 中仍有 `1` 个 final-reply failed,且没有 Supervisor assistant。只看项目已完成或后续确定性回复,容易把这轮误写成 PASS。 +- 原因:项目 completion gates、用户是否收到收束回复和 Provider lifecycle 是否全成功是三个独立事实。确定性回复可以补齐已完成项目的用户出口,但不能反向证明失败的 Provider 请求成功,也不能覆盖失败 identity。 +- 处理:兜底条件必须同时锁定 `autonomous-game-build`、`project-supervisor`、当前 revision completion gates 全通过和 final-reply 阶段;优先使用非空 `plan.response`,为空时才生成当前 revision 已完成生成并通过静态、桌面和移动试玩的固定回复。普通 Agent、未收敛、门禁未通过或 reconciliation 一律继续失败关闭,并原样保留 Provider failed lifecycle 证据。 +- 验证:把已有外部轮次继续标记为 **FAIL**。修复后另起独立真实外部 E2E,在同一轮同时证明唯一 Supervisor assistant、完成门禁、Provider lifecycle、零残留、零重复和零泄漏;复验完成前不得宣称外部 Provider 全链路 PASS,也不得与旧失败轮拼接。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/`、`apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 +- 最新复验:新 fallback 已真实命中,父 Supervisor 为 `idle / completed`,`turn.report` 为 `settled`,唯一 assistant 为 `44` 字符;pending、retry、handoff、finalization、reconciliation、重复、API Key 和路径泄漏均为 `0`。“完成后不回复”已解决,但该轮仍是 **FAIL**:`105` 个 Provider identity 只有 `103 completed / 2 failed`,两个原始专业 Agent 失败虽均由 repair 恢复,最终仍因 `supervisor-swarm-private-body-public-event-leak` 未通过。 +- 泄漏定位:两个专业 Agent 的 `149 / 123` 字符失败正文,分别对应 SHA-256 前缀 `494ce8 / 3089ad`,进入 `4` 条 `event.detail` 和 `2` 条 `agent.runtime.background_task.failed.error`。这些内容是 delivery result,不是 userTask、委派任务或对话正文;不要把该问题误归因到 final-reply fallback。 +- 脱敏处理:私有 `state.error` 与私有 delivery 应保留完整诊断;公共 event / agentDb 只投影 `errorSha256 / errorChars /` 稳定 `failureKind`。禁止按正文黑名单打补丁,也禁止把失败状态伪装为成功来消除泄漏。 +- 复验要求:公共投影修复后必须另起独立真实外部 E2E,重新核对同轮 Provider lifecycle、唯一回复、残留、重复和泄漏;当前仍未 PASS。 +- 最终复验:修复后另起的独立真实外部轮次已取得 `status=PASS`、`evidence=complete`、`privacy scan=complete`。此前 `114` identity 和 `105` identity 两个 **FAIL** 仍是各自独立的历史失败,未与本轮拼接;最终 PASS 仅由这个单个新轮次的完整证据构成,当前状态现已 **PASS**。 +- Provider 与恢复证据:`84` 个 Provider identity 的 `started / terminal / completed` 均为 `84`,`failed / retry / open / duplicate` 均为 `0`。`1` 个原专业任务为 `budget-exhausted`,唯一 repair `completed` 且 `recovered`;最终 child 为 `2 completed + 1 historical failed`,全部任务均已终态。 +- 收束与项目证据:父 Supervisor 为 `idle / completed`,`turn.report=settled`,唯一 assistant 为 `297` 字符,`completed audit=1`,finalization stages 为 `4`。项目 revision `0 -> 4`,`game/index.html` 为 `7639` bytes 且已变化,static smoke passed;`lane-defense-v1` 的 desktop / mobile 浏览器验证均通过并取得 `37/37`。 +- 零值与清理证据:pending / confirmation / user-input / provider batch / retry / handoff / tool-plan handoff / finalization 残留 / reconciliation / duplicate 全为 `0`;Provider payload / private body / API Key / project path / config path / log / browser report leak 全为 `0`;人工 approve / answer / steer 全为 `0`。Runner 与 AppData 已清,项目因 `--keep-project` 暂留后由主线程清理。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 82ceae25d..44a8af9e9 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -66,6 +66,22 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .cod 2026-07-22 补充:自主构建的最终 `preview.validate` 在当前 revision 失败后,修复责任必须继续服从 Project Supervisor 的只编排边界。父 run 尚无协作事实时可沿用总控直接修复兼容路径;一旦 durable 协作事实已建立且 `orchestratorOnlyAfterDelegation=true`,活性门不得再强迫 Supervisor 调用 `file.write / file.patch / project.patchset`。没有 ready 回执且委派容量未满时,可向 `code-prototype` 创建 `repairOfDelegationId=null / runId=null` 的新后续修复任务,并把最新浏览器诊断和 `game/index.html` 验收产物写入合同;已有 ready 未认领回执或 active delivery 已达 3 个时,原生工具目录必须只保留 `agent.run_status`,先原子认领既有交付,不得创建第四次委派。专业 Agent 推进到更高 revision 后,固定顺序为“认领 ready delivery -> 取得当前 revision 的静态通过凭证 -> 父 Supervisor 重跑固定试玩”。每个固定 `data-playtest-id` 在对应动作发生时必须恰好匹配一个可见、启用且真实可点击的 HTMLElement;缺失、重复、隐藏或 disabled 都失败关闭。失败 revision、专业修改、总控复验与最终试玩之间不得用伪造 mutation 衔接。 +2026-07-22 外部自主构建 E2E 的最新失败轮已推进到 revision 5,最终浏览器试玩 `37/37`、Supervisor 持久计划 `8/8`;但 `image.inspect` 视觉请求与最终回复先后命中同一 deserialize fingerprint。终局 `114` 个 Provider request identity 中 `113 completed / 1 final-reply failed`,没有产生 Supervisor assistant,整轮因此仍为 **FAIL**,不得写成外部 Provider 全链路 PASS。 + +该问题的收束修复严格限制为 `autonomous-game-build + project-supervisor + completion gates 已通过` 后的 `final-reply`。优先使用非空 `plan.response`;为空时才生成确定性回复,明确当前 revision 已完成生成并通过静态、桌面和移动试玩。普通 Agent、尚未收敛、任一完成门禁未通过或存在 reconciliation 时继续失败关闭。Provider lifecycle 中真实的 final-reply 失败证据必须保留,兜底只保证已完成项目能向用户收束,不把失败请求改写为 completed。修复后必须重新运行一轮独立真实外部 E2E;该轮完成前不能宣称外部 Provider 全链路 PASS。 + +2026-07-22 修复后的最新独立真实外部轮次确认新 fallback 已命中:父 Supervisor 终局为 `idle / completed`,`turn.report` 为 `settled`,只产生 `1` 条 `44` 字符的 Supervisor assistant;pending、retry、handoff、finalization、reconciliation、重复、API Key 和路径泄漏均为 `0`。因此“完成后不回复”已在该轮解决。 + +该轮整体仍为 **FAIL**,不能称为 PASS:`105` 个 Provider identity 中 `103 completed / 2 failed`;两个原始专业 Agent 失败均由 repair 恢复,最终验收却命中 `supervisor-swarm-private-body-public-event-leak`。脱敏定位共 `6` 处:两个专业 Agent 的失败正文分别为 `149 / 123` 字符、对应 SHA-256 前缀 `494ce8 / 3089ad`,进入 `4` 条 `event.detail` 和 `2` 条 `agent.runtime.background_task.failed.error`;这些内容均属于 delivery result,不是 userTask、委派任务或对话正文,与 final-reply fallback 无直接关系。 + +修复必须保留私有 `state.error` 和私有 delivery 的诊断正文,公共 event / agentDb 只写 `errorSha256 / errorChars /` 稳定 `failureKind`;禁止采用正文黑名单,也禁止把真实失败改写为成功。修复后必须另起独立真实外部 E2E,当前外部 Provider 全链路仍未 PASS。 + +2026-07-22 最终独立真实外部轮次已完整 **PASS**:`status=PASS`、`evidence=complete`、`privacy scan=complete`。本轮共有 `84` 个 Provider identity,`started / terminal / completed` 均为 `84`,`failed / retry / open / duplicate` 均为 `0`;`1` 个原专业任务以 `budget-exhausted` 终止,唯一 repair 已 `completed` 并 `recovered`,最终 child 为 `2 completed + 1 historical failed`,所有任务均处于终态。 + +父 Supervisor 最终为 `idle / completed`,`turn.report` 为 `settled`,只产生 `1` 条 `297` 字符的 Supervisor assistant,`completed audit=1`,finalization 完成 `4` 个 stages。项目 revision 从 `0 -> 4`,`game/index.html` 为 `7639` bytes 且内容已变化,`game.static_smoke` passed;`lane-defense-v1` 的 desktop / mobile 浏览器验证均通过,固定试玩为 `37/37`。 + +终局 pending / confirmation / user-input / provider batch / retry / handoff / tool-plan handoff / finalization 残留 / reconciliation / duplicate 全为 `0`;Provider payload / private body / API Key / project path / config path / log / browser report leak 全为 `0`;人工 approve / answer / steer 全为 `0`。Runner 与 AppData 已清理,项目因 `--keep-project` 暂留后由主线程清理。此前 `114` identity 与 `105` identity 两个 **FAIL** 继续保留为独立历史失败,证据未与本轮拼接;最终 PASS 是这个单个新轮次的完整证据,当前外部 Provider 全链路状态现已 **PASS**。 + 2026-07-15 起,Runtime V1.1 文档的“V1.17 单 Agent 持久计划”作为后台工具规划进度的新事实源。`submit_agent_tool_plan` 新增 nullable `planUpdate={explanation,steps[{step,status}]}`;步骤只接受 `pending / in_progress / completed`,最多 8 步且至多一个 `in_progress`。结构化计划一旦建立,legacy `plan` 只作旧协议 fallback;终态步骤必须保留,`planRevision` 只在真实变化时单调递增,工具 action 下标不得自动完成结构化步骤,存在未完成步骤时不得写最终回复或 completed。 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。