From 2233f1b7c4e8af02ee6a3eec2025357947adec30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Sat, 3 Oct 2026 18:54:12 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AF=95=E7=8E=A9?= =?UTF-8?q?=E5=9B=9E=E5=8C=85=E9=81=97=E6=BC=8F=E5=8F=8C=E7=AB=AF=E7=8E=A9?= =?UTF-8?q?=E6=B3=95=E5=A4=B1=E8=B4=A5=E8=AF=81=E6=8D=AE=EF=BC=88#548?= =?UTF-8?q?=EF=BC=89=20(#598)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 试玩报告已记录玩法阻断,但工具回包只展示视觉结果,Agent 无法从“画面正常、整体失败”定位原因;返回的私有报告路径也不能通过通用文件工具读取。 本次增加逐视口 gameplayResults,返回有界诊断、断言和初末状态,并从同一份投影生成摘要。明确区分视觉检查与玩法检查、未通过与未执行;保留现有通过判定、验证预算和私有目录保护。同步更新证据合同、提示词与 skill pack。 验证: - Rust 定向测试、提示词构建与源码边界检查通过。 - 最终 MCP 成功/失败回包均检查逐视口状态、原因、断言及截图。 - 显式运行 4 个真实浏览器用例:双端成功、禁用控件、真实点击后 phase 未进入 playing、仅移动端失败;对照持久报告核对回包与摘要。 - rustfmt、编码、文档索引、skill pack、git diff --check 通过。 - 未运行全量测试或真实模型端到端验证。 Closes #548 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/598 --- .../prompts/runtime/texts/direct.json | 10 +- .../references/browser-evidence-contract.md | 2 + .../resources/agc-skills/manifest.json | 4 +- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/direct_tool_bridge.rs | 97 ++++++ .../src-tauri/src/agent/direct_validation.rs | 301 ++++++++++++++++-- .../src-tauri/src/browser/tests.rs | 88 ++++- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 + 8 files changed, 473 insertions(+), 33 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json index 8ba3c986f..a544d209e 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json @@ -17,8 +17,14 @@ "browser.status": "整体 passed={}", "browser.notStarted": "本次未启动 Chromium", "browser.evidence": "[AGC 浏览器事实证据]\nattempt={attempt}; completionError={completion_status}; browser={browser_status}; codeFingerprintChanged={output_changed}\n{}\n诊断与平台素材运行时观察:{}\n交互探针:{}\n客户端已保存结构化证据;它是事实输入,不代表 Codex 已阅读截图或已经完成修复。当前 cwd 是项目根目录;按需读取实际文件,并自行决定是否修改、再次试玩或直接回复。AGC 只负责启动浏览器、采集证据和执行项目边界,Codex 负责解释结果。", - "browser.noFailureDetails": "无额外硬失败详情", - "browser.noVisibleControls": "未找到可执行的可见控件", + "browser.noFailureDetails": "无额外视觉检查失败详情", + "browser.noVisibleControls": "无建议性交互探针记录", + "browser.gameplayNotRun": "玩法检查:未执行(visual 仅提供视觉证据)。", + "browser.gameplayMissing": "固定场景玩法结果缺失,不能确认通过。", + "browser.gameplayNoDiagnostic": "无诊断记录", + "browser.gameplayNoUnmetAssertion": "无", + "browser.gameplayViewport": "{viewport}: gameplayPassed={passed};首条玩法诊断:{diagnostic};首个未通过断言:{assertion}", + "browser.gameplayEvidence": "[固定场景玩法证据]\n{}\n断言 passed=false 表示未通过,也可能因前序阻断而未执行;修复以具体诊断为准,不把所有未通过项视为独立故障。固定场景通过不代表完整关卡已验证;reportPath 仅为宿主证据引用,无需读取私有报告。", "system.role": "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,负责当前任务的执行。先理解用户意图:普通对话直接回答,项目请求按需要检查、修改、运行和验证,并用简洁中文报告真实结果。", "system.workspaceBoundary": "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。", "system.toolAuthorization": "AGC 工具授权:agc_tools 使用当前登录会话。工具返回 401/403 时,报告登录或权限状态异常并停止,交由用户处理登录和权限。", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md index 6db5127e8..33c8ac092 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md @@ -10,6 +10,8 @@ Canvas/WebGL probes can show whether registered image sources entered a core ren `mode=visual` captures desktop/mobile screenshots, loading, layout and diagnostics without clicking gameplay controls. It does not prove that the game loop works. `mode=gameplay` uses the fixed approved scenario (`generic-v1` by default, or the existing `tetris-v1` / `lane-defense-v1` contract), not an agent-written autopilot. It exercises the scenario independently in desktop and mobile viewports and requires both results. The receipt declares `gameplayViewports` separately from `visualViewports`. Older reports with only a desktop `playtest` result do not prove mobile gameplay. These bounded scenarios do not establish completion of an arbitrary long level. +Use each viewport's `gameplayResults` to diagnose failures: start with its first `diagnostics` entry, then inspect `assertions` and the recorded initial/final phase, sequence and level. An assertion with `passed=false` may not have run because an earlier step blocked; do not treat every unmet assertion as an independent defect. Missing viewport evidence is not a pass. In visual mode, `gameplayResults` is empty because gameplay was not run. `reportPath` is a private host evidence reference; use the returned diagnostics instead of trying to read that report. + For `generic-v1`, expose exactly one `script#playable-web-game-state[type="application/json"]` whose JSON is a projection of actual game state: ```json diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 4625c1ca2..12678da03 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.35", + "version": "2026-08-26.37", "skills": [ { "name": "agc-unity-editor", @@ -140,7 +140,7 @@ "references/browser-evidence-contract.md", "references/runner-physics.mjs" ], - "sha256": "e0e7bdca529e068ce2974a5e312a267108ab5acb20e8f9d7c0c22d049d90b796" + "sha256": "6800059c4e7bae70b0b42ec47175fc85e38d789e1759dca5df2067f715dc0657" }, { "name": "agc-client-projection", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index fc4843f6d..db9db4abf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -30,6 +30,8 @@ mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; mod direct_validation; +#[cfg(test)] +pub(crate) use direct_validation::browser_tool_result; mod generation; pub(crate) mod json_sidecar; pub(crate) mod redaction; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 31cd98b59..30a7ad3bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -4421,6 +4421,103 @@ pub(super) fn drain_analytics_test_writer( #[cfg(test)] mod tests { + #[test] + fn browser_gameplay_results_reach_model_with_matching_viewports_and_screenshots() { + for passed in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let evidence = if passed { + super::super::direct_validation::tests::browser_evidence_fixture(temp.path()) + } else { + super::super::direct_validation::tests::failed_browser_evidence_fixture(temp.path()) + }; + for viewport in &evidence.viewport_results { + image::RgbaImage::new(2, 2) + .save(&viewport.screenshot_path) + .unwrap(); + } + let receipt = super::super::direct_validation::browser_tool_result( + temp.path(), + &evidence, + Some(crate::browser::BrowserPlaytestScenario::GenericV1), + 1, + ); + assert!(evidence.viewport_results.iter().all(|result| result.passed)); + assert_eq!(receipt["passed"], passed); + let outcome = bridge_validation_result( + temp.path(), + Ok(receipt.clone()), + |report, screenshots| BrowserPlaytestError::PlaytestNotPassed { + report, + screenshots, + }, + ) + .map_err(|failure| { + let error = ToolCallError::from(&failure); + assert_eq!(error.error["PlaytestNotPassed"]["report"], receipt); + assert!(error.error["PlaytestNotPassed"] + .get("screenshots") + .is_none()); + error + }); + let state = direct_tool_bridge_state(temp.path().to_path_buf()); + let response = + compose_direct_tool_outcome(&state, "agc_browser_playtest", "{}", false, outcome); + assert_eq!(response["isError"], !passed); + let text = response["content"][0]["text"].as_str().unwrap(); + let model_receipt: Value = + serde_json::from_str(&text[text.find('{').unwrap()..]).unwrap(); + assert_eq!(model_receipt, receipt); + assert_eq!( + response["content"] + .as_array() + .unwrap() + .iter() + .filter(|block| block["type"] == "image") + .count(), + 2 + ); + let summary = model_receipt["summary"].as_str().unwrap(); + for (index, entry) in evidence.viewport_playtests.iter().enumerate() { + let report = &model_receipt["gameplayResults"][index]; + assert_eq!( + report["viewport"], + serde_json::to_value(entry.viewport).unwrap() + ); + assert_eq!(report["passed"], entry.result.passed); + assert_eq!( + report["assertions"], + serde_json::to_value(&entry.result.assertions).unwrap() + ); + assert_eq!(report["finalPhase"], "ready"); + assert_eq!( + report["finalSequence"], + entry.result.final_sequence.unwrap() + ); + let viewport = report["viewport"].as_str().unwrap(); + let line = summary + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry.result.passed + ))); + if let Some(reason) = entry.result.diagnostics.first() { + assert_eq!(report["diagnostics"][0], reason.as_str()); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry + .result + .assertions + .iter() + .find(|assertion| !assertion.passed) + { + assert!(line.contains(&assertion.name)); + } + } + } + } + #[tokio::test] async fn host_cancellation_releases_queued_image_without_a_new_post() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index d2b6f847b..a42aed1b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -395,6 +395,137 @@ async fn finish_async( .map_err(|_| "验证回执任务退出".to_string())? } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GameplayViewportEvidence { + viewport: crate::browser::BrowserValidationViewport, + passed: bool, + diagnostics: Vec, + assertions: Vec, + initial_phase: Option, + initial_sequence: Option, + initial_level: Option, + final_phase: Option, + final_sequence: Option, + final_level: Option, +} + +/// 只投影模型修复所需的玩法事实;完整报告继续留在宿主私有目录。 +pub(crate) fn browser_tool_result( + root: &Path, + evidence: &crate::browser::BrowserValidationResult, + scenario: Option, + sequence: usize, +) -> Value { + use crate::browser::BrowserValidationViewport::{Desktop, Mobile}; + + let gameplay_results = if scenario.is_some() { + [Desktop, Mobile] + .into_iter() + .map(|viewport| { + let result = evidence + .viewport_playtests + .iter() + .find(|entry| entry.viewport == viewport) + .map(|entry| &entry.result); + GameplayViewportEvidence { + viewport, + passed: result.is_some_and(|result| result.passed), + diagnostics: result + .map(|result| { + result + .diagnostics + .iter() + .take(4) + .map(|message| truncate_agent_runtime_text(message, 511)) + .collect() + }) + .unwrap_or_else(|| { + vec![prompt_text!("direct.browser.gameplayMissing").into()] + }), + assertions: result + .map(|result| result.assertions.clone()) + .unwrap_or_default(), + initial_phase: result.and_then(|result| result.initial_phase), + initial_sequence: result.and_then(|result| result.initial_sequence), + initial_level: result.and_then(|result| result.initial_level), + final_phase: result.and_then(|result| result.final_phase), + final_sequence: result.and_then(|result| result.final_sequence), + final_level: result.and_then(|result| result.final_level), + } + }) + .collect::>() + } else { + Vec::new() + }; + let gameplay_summary = if scenario.is_none() { + prompt_text!("direct.browser.gameplayNotRun").to_string() + } else { + let lines = gameplay_results + .iter() + .map(|result| { + format!( + prompt_text!("direct.browser.gameplayViewport"), + viewport = match result.viewport { + Desktop => "desktop", + Mobile => "mobile", + }, + passed = result.passed, + diagnostic = result + .diagnostics + .first() + .map(String::as_str) + .unwrap_or(prompt_text!("direct.browser.gameplayNoDiagnostic")), + assertion = result + .assertions + .iter() + .find(|assertion| !assertion.passed) + .map(|assertion| assertion.name.as_str()) + .unwrap_or(prompt_text!("direct.browser.gameplayNoUnmetAssertion")), + ) + }) + .collect::>(); + format!( + prompt_text!("direct.browser.gameplayEvidence"), + lines.join("\n") + ) + }; + let summary = format!( + "{}\n{}", + render_direct_browser_tool_evidence(root, evidence, sequence), + gameplay_summary + ); + let screenshots: Vec<_> = evidence + .viewport_results + .iter() + .filter_map(|viewport| { + viewport + .screenshot_path + .strip_prefix(root) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + }) + .collect(); + let passed = evidence.passed + && scenario.is_none_or(|scenario| { + crate::browser::required_viewport_playtests_passed( + scenario, + &evidence.viewport_playtests, + ) + }); + json!({ + "kind":"browser", "mode":if scenario.is_some() {"gameplay"} else {"visual"}, + "scenario":scenario, "passed":passed, + "scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint), + "summary":summary, "screenshots":screenshots, + "reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|path|path.to_string_lossy().replace('\\', "/")), + "visualViewports":["desktop","mobile"], + "gameplayViewports":evidence.viewport_playtests.iter().map(|entry|entry.viewport).collect::>(), + "gameplayResults":gameplay_results, + "coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"} + }) +} + pub(super) async fn run_browser(root: &Path, arguments: &Value) -> Result { run_browser_with_budget(root, arguments, true).await } @@ -466,33 +597,9 @@ async fn run_browser_with_budget( .await; let (result, passed) = match evidence { Ok(evidence) => { - let screenshots: Vec<_> = evidence - .viewport_results - .iter() - .filter_map(|v| { - v.screenshot_path - .strip_prefix(root) - .ok() - .map(|p| p.to_string_lossy().replace('\\', "/")) - }) - .collect(); - let gameplay_passed = scenario.is_none_or(|scenario| { - crate::browser::required_viewport_playtests_passed( - scenario, - &evidence.viewport_playtests, - ) - }); - let passed = evidence.passed && gameplay_passed; - let summary = render_direct_browser_tool_evidence(root, &evidence, sequence); - ( - json!({"kind":"browser", "mode":input.mode, "scenario":scenario, - "scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint), "summary":summary, "screenshots":screenshots, - "reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|p|p.to_string_lossy().replace('\\', "/")), - "visualViewports":["desktop","mobile"], - "gameplayViewports":evidence.viewport_playtests.iter().map(|v|v.viewport).collect::>(), - "coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"}}), - passed, - ) + let result = browser_tool_result(root, &evidence, scenario, sequence); + let passed = result["passed"] == true; + (result, passed) } Err(error) => ( json!({"mode":input.mode,"needsReconciliation":error.starts_with("browser-cleanup-unconfirmed:"),"error":truncate_agent_runtime_text(&error, 1800)}), @@ -599,9 +706,147 @@ pub(super) async fn run_command(root: &Path, arguments: &Value) -> Result crate::browser::BrowserValidationResult { + let mut viewports = Vec::new(); + let mut playtests = Vec::new(); + for viewport in ["desktop", "mobile"] { + viewports.push(json!({ + "viewport":viewport, "width":1280, "height":720, + "finalUrl":"http://127.0.0.1/", "title":"fixture", "readyState":"complete", + "visibleTextSummary":"", "visibleTextCharacterCount":0, "domCharacterCount":0, + "expectedText":[], "consoleErrors":[], "consoleWarnings":[], "exceptions":[], + "failedRequests":[], "canvases":[], "diagnostics":[], "passed":true, + "blockedPopupCount":0, "blockedDialogCount":0, "blockedDownloadCount":0, + "blockedPermissionCount":0, "blockedServiceWorkerCount":0, + "screenshotPath":root.join(format!("{viewport}.png")) + })); + playtests.push(json!({"viewport":viewport, "result":{ + "scenario":"generic-v1", "scenarioFingerprint":"fixture", "passed":true, + "initialSequence":0, "initialPhase":"ready", "initialLevel":1, + "finalSequence":3, "finalPhase":"ready", "finalLevel":1, + "assertions":[ + {"name":"start-control-clicked", "passed":true}, + {"name":"start-phase-playing", "passed":true}, + {"name":"restart-control-clicked", "passed":true} + ], "diagnostics":[] + }})); + } + serde_json::from_value(json!({ + "schemaVersion":"browser-validation.v1", "url":"http://127.0.0.1/", + "browser":{"kind":"chrome", "product":"fixture", "protocolVersion":"1.3"}, + "passed":true, "viewportResults":viewports, "viewportPlaytests":playtests, + "diagnostics":[], "completedAtUnixMs":1, + "evidence":{"root":root, "reportPath":root.join("validation.json")} + })) + .unwrap() + } + + pub(crate) fn failed_browser_evidence_fixture( + root: &Path, + ) -> crate::browser::BrowserValidationResult { + let mut evidence = browser_evidence_fixture(root); + evidence.passed = false; + for (index, entry) in evidence.viewport_playtests.iter_mut().enumerate() { + entry.result.passed = false; + entry.result.final_phase = Some(crate::browser::BrowserPlaytestPhase::Ready); + entry.result.final_sequence = Some(index as u64); + entry.result.diagnostics = vec![if index == 0 { + "固定试玩控件 start 处于 disabled 状态".into() + } else { + "generic-v1 start 后必须进入 playing".into() + }]; + for (assertion_index, assertion) in entry.result.assertions.iter_mut().enumerate() { + assertion.passed = index == 1 && assertion_index == 0; + } + } + evidence + } + + #[test] + fn browser_tool_projection_distinguishes_gameplay_success_visual_only_and_missing_viewport() { + use crate::browser::BrowserPlaytestScenario::GenericV1; + let root = tempfile::tempdir().unwrap(); + for mode in ["gameplay", "visual", "missing-mobile"] { + let mut evidence = browser_evidence_fixture(root.path()); + let scenario = if mode == "visual" { + evidence.viewport_playtests.clear(); + None + } else { + Some(GenericV1) + }; + if mode == "missing-mobile" { + evidence.viewport_playtests.pop(); + evidence.passed = false; + } + let receipt = browser_tool_result(root.path(), &evidence, scenario, 1); + let summary = receipt["summary"].as_str().unwrap(); + assert_eq!(receipt["passed"], mode != "missing-mobile"); + if mode == "visual" { + assert_eq!(receipt["gameplayResults"], json!([])); + assert!(summary + .lines() + .any(|line| line.starts_with("玩法检查:未执行"))); + assert_eq!(receipt["coverage"], "visual-only"); + continue; + } + for (index, viewport) in ["desktop", "mobile"].iter().enumerate() { + let result = &receipt["gameplayResults"][index]; + assert_eq!(result["viewport"], *viewport); + if mode == "missing-mobile" && index == 1 { + assert_eq!(result["passed"], false); + assert!(result["diagnostics"][0].as_str().unwrap().contains("缺失")); + assert!(summary.contains("mobile: gameplayPassed=false")); + assert_eq!(result["assertions"], json!([])); + assert_eq!(result["finalPhase"], Value::Null); + } else { + assert_eq!(result["passed"], true); + assert_eq!(result["diagnostics"], json!([])); + assert!(result["assertions"] + .as_array() + .unwrap() + .iter() + .all(|a| a["passed"] == true)); + assert!(summary.contains(&format!("{viewport}: gameplayPassed=true"))); + } + } + } + } + + #[test] + fn browser_tool_projection_bounds_diagnostics_without_losing_either_blocker() { + let root = tempfile::tempdir().unwrap(); + let mut evidence = failed_browser_evidence_fixture(root.path()); + for viewport in &mut evidence.viewport_playtests { + viewport + .result + .diagnostics + .extend((0..8).map(|_| "额外诊断".repeat(200))); + } + let receipt = browser_tool_result( + root.path(), + &evidence, + Some(crate::browser::BrowserPlaytestScenario::GenericV1), + 1, + ); + for (index, entry) in evidence.viewport_playtests.iter().enumerate() { + let diagnostics = receipt["gameplayResults"][index]["diagnostics"] + .as_array() + .unwrap(); + assert_eq!(diagnostics.len(), 4); + assert_eq!(diagnostics[0], entry.result.diagnostics[0]); + assert!(diagnostics + .iter() + .all(|d| d.as_str().unwrap().chars().count() <= 512)); + assert!(receipt["summary"] + .as_str() + .unwrap() + .contains(&entry.result.diagnostics[0])); + } + } + fn project() -> (tempfile::TempDir, std::path::PathBuf) { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 9d8733862..cb5d1cf8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -1514,6 +1514,41 @@ async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_ } } +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_reports_start_click_without_playing_phase() { + let result = run_stable_generic_viewport_fixture(false, Some( + r#""#, + )).await; + assert!(!result.passed); + assert!(result + .viewport_results + .iter() + .all(|viewport| viewport.passed)); + for entry in &result.viewport_playtests { + let playtest = &entry.result; + assert!(!playtest.passed); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.final_sequence, Some(1)); + for (name, expected) in [ + ("start-control-clicked", true), + ("start-phase-playing", false), + ] { + assert_eq!( + playtest + .assertions + .iter() + .find(|assertion| assertion.name == name) + .unwrap() + .passed, + expected + ); + } + let reason = &playtest.diagnostics[0]; + assert!(reason.contains("必须进入 playing"), "{reason}"); + } +} + async fn run_stable_generic_viewport_fixture( mobile_action_broken: bool, start_markup: Option<&str>, @@ -1556,7 +1591,7 @@ async fn run_stable_generic_viewport_fixture( }; document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => { if (event.isTrusted && state.phase === 'ready') { - advance(() => { state.phase = 'playing'; }); + advance(() => { state.phase = event.currentTarget.dataset.startPhase || 'playing'; }); } }); const primary = document.querySelector('[data-playtest-id="primary-action"]'); @@ -1641,6 +1676,57 @@ async fn run_stable_generic_viewport_fixture( let persisted: serde_json::Value = serde_json::from_slice(&fs::read(&result.evidence.report_path).unwrap()).unwrap(); assert_eq!(persisted["viewportPlaytests"].as_array().unwrap().len(), 2); + let receipt = crate::agent::browser_tool_result( + evidence.path(), + &result, + Some(BrowserPlaytestScenario::GenericV1), + 1, + ); + assert_eq!(receipt["passed"], persisted["passed"]); + for (index, entry) in persisted["viewportPlaytests"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + let projected = &receipt["gameplayResults"][index]; + assert_eq!(projected["viewport"], entry["viewport"]); + let viewport = entry["viewport"].as_str().unwrap(); + let line = receipt["summary"] + .as_str() + .unwrap() + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry["result"]["passed"] + ))); + for field in [ + "passed", + "assertions", + "initialPhase", + "initialSequence", + "initialLevel", + "finalPhase", + "finalSequence", + "finalLevel", + ] { + assert_eq!(projected[field], entry["result"][field], "{field}"); + } + if let Some(reason) = entry["result"]["diagnostics"][0].as_str() { + assert_eq!(projected["diagnostics"][0], reason); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry["result"]["assertions"] + .as_array() + .unwrap() + .iter() + .find(|assertion| assertion["passed"] == false) + { + assert!(line.contains(assertion["name"].as_str().unwrap())); + } + } result } diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 2c56e16fb..b9c3bb849 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -195,6 +195,8 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema ### 分层验证与预算 - 复用客户端浏览器与现有固定玩法场景。视觉检查采集双端画面/布局/资源/诊断;玩法检查分别在 desktop/mobile 执行明确的固定场景和真实输入,按视口保存结果。旧报告缺少移动端玩法结果时保持未知,不补通过。报告必须声明检查层级;视觉通过不能宣称玩法通过,固定场景通过也不能宣称覆盖未执行的完整关卡。 +- 浏览器工具回执在 `mode=gameplay` 时新增 `gameplayResults` 双端有界投影,每个视口包含 `viewport`、`passed`、`diagnostics`、`assertions` 及 `initialPhase` / `initialSequence` / `initialLevel`、`finalPhase` / `finalSequence` / `finalLevel`。`assertions` 仅列当前固定场景的断言名称和通过状态;每端最多公开 4 条诊断,每条最多 512 字符。缺少某端结果时仍返回该视口 `passed=false`,诊断说明证据缺失,断言为空且六个状态字段为 `null`;断言的 `passed=false` 可包含未执行,不能据此断言该断言已独立执行且失败。`mode=visual` 的 `gameplayResults` 为空,并在摘要中明确玩法未执行。摘要从同一投影给出每端首条诊断和首个未通过断言,不能与结构化结果相矛盾。工具回执不得展开完整宿主报告;`reportPath` 只作宿主证据引用。宿主报告结构、验收门禁、持久化预算和私有路径保护保持原合同,旧回执不回填新字段。 +- 回执验收覆盖 start 禁用、点击后 phase 不符合和玩法成功;逐视口核对持久报告与摘要中的状态、首条诊断和首个未通过断言,并检查成功、失败两条最终 MCP 回包路径。 - 输入或碰撞改变先做定点玩法检查;纯图像/颜色变化做视觉检查;首次交付和影响闭环的修改做所需玩法验证。新增失败或相关代码变化才重跑对应层,不因改说明文字重复完整验证。 - 内置试玩和客户端托管的外部 Node/npm 验证共用当前 clientTurnId 的持久化预算。客户端分配递增执行序号,模型提供的旧 attempt 仅作兼容输入,不能减少计数或重置预算;同一轮错误反馈、工具切换和进程重启均不能刷新已消费次数。 - 新增本地 validation.maxRuns(默认 3,正整数)独立于 llm.maxRetries;显式配置原样使用,不按角色或运行模式改写。超限直接返回已用/上限和最近证据,停止新的验证。预检与正常构建不计作重复试玩。 From e5fa123483ee586d70eea3a88cc25fba0bec066c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:04:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Claude=20Direct=20MCP?= =?UTF-8?q?=20=E5=9B=9E=E5=90=88=E6=8E=88=E6=9D=83=E6=AD=BB=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内部 Claude MCP 使用 Direct 工具桥并绑定当前回合授权 保留外部 MCP 隔离模式并补充回归测试与排障记录 --- .../src-tauri/src/agent/claude_code_cli.rs | 3 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 15 ++++++++- .../src-tauri/src/agent/direct_tools_mcp.rs | 31 +++++++++++++++++-- docs/project-memory/shared-memory/pitfalls.md | 3 +- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 0ea85c3b7..a9e238e02 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -960,7 +960,8 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); - let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?; + let (mcp_url, mcp_token) = + start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; direct_turn_trace("claude-mcp-loopback-ready"); let _mcp_guard = ExternalMcpStopGuard; let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 30a7ad3bb..a3a32f57f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -4236,8 +4236,9 @@ pub(crate) async fn start_direct_tool_bridge( pub(crate) async fn start_external_client_tool_bridge( root: &Path, controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result { - start_tool_bridge_for_source(root, controlled_web_search, false).await + start_tool_bridge_for_source(root, controlled_web_search, direct_turn_execution).await } async fn start_tool_bridge_for_source( @@ -4518,6 +4519,18 @@ mod tests { } } + #[tokio::test] + async fn external_client_bridge_can_be_enabled_for_a_direct_turn() { + let temporary = tempfile::tempdir().unwrap(); + init_local_game_project_at(temporary.path(), "external-direct-turn", "外部直连回合") + .unwrap(); + let bridge = start_external_client_tool_bridge(temporary.path(), false, true) + .await + .expect("direct Claude bridge"); + assert!(bridge.state.direct_turn_execution); + drop(bridge); + } + #[tokio::test] async fn host_cancellation_releases_queued_image_without_a_new_post() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 50ff3abf5..11275387c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -41,6 +41,7 @@ tokio::task_local! { } pub(crate) struct ExternalMcpServer { + _direct_turn_guard: Option, _bridge: super::direct_tool_bridge::DirectToolBridge, task: tokio::task::JoinHandle<()>, } @@ -1759,6 +1760,21 @@ async fn handle_external_mcp_http_request( pub(crate) async fn start_external_mcp_loopback( root: &Path, controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, false).await +} + +pub(crate) async fn start_external_mcp_loopback_for_direct_turn( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, true).await +} + +async fn start_external_mcp_loopback_with_mode( + root: &Path, + controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result<(String, String), String> { let root = validate_direct_tools_project_root(root)?; let session = current_platform_session() @@ -1771,9 +1787,17 @@ pub(crate) async fn start_external_mcp_loopback( let address = listener .local_addr() .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; - let bridge = - super::direct_tool_bridge::start_external_client_tool_bridge(&root, controlled_web_search) - .await?; + let bridge = super::direct_tool_bridge::start_external_client_tool_bridge( + &root, + controlled_web_search, + direct_turn_execution, + ) + .await?; + let direct_turn_guard = if direct_turn_execution { + Some(bridge.begin_user_turn()?) + } else { + None + }; let state = ExternalMcpHttpState { bridge_url: bridge.url().to_string(), root, @@ -1798,6 +1822,7 @@ pub(crate) async fn start_external_mcp_loopback( drop(previous); } *guard = Some(ExternalMcpServer { + _direct_turn_guard: direct_turn_guard, _bridge: bridge, task, }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 75a6d0d4f..67ff83da2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6289,7 +6289,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 - **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 - **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 7(内部 Claude MCP 桥误用外部只读模式)**:Claude Code sidecar 使用的 loopback MCP 原本调用 `start_external_client_tool_bridge(..., false)`,桥状态 `direct_turn_execution=false` 且没有 `begin_user_turn()` 授权;`agc_register_delivery_contract`、`agc_delivery_status`、`agc_update_plan` 每次都会返回 `ToolRequiresDirectTurn`,模型收到错误后又重复注册计划,最终陷入反馈死循环直到超时。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 - **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。 From d4be3dd97a4088535096f79c4e80d69866e88fef Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:21:18 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20Claude=20=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E9=94=99=E8=AF=AF=E5=B1=95=E7=A4=BA=E4=B8=8E=20MCP=20?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端保留 401/403/408/429/5xx 状态并补回归测试 按项目隔离 Claude MCP 注册表并防止旧回合误停新桥 更新 DirectProject 排障记录 --- .../src-tauri/src/agent/claude_code_cli.rs | 12 ++++-- .../src-tauri/src/agent/direct_tools_mcp.rs | 42 +++++++++++++------ .../chat/conversation/directTurnFailure.ts | 21 ++++++++-- .../tests/directTurnFailure.test.ts | 34 +++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 3 +- 5 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/directTurnFailure.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index a9e238e02..f0f01ad0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -94,11 +94,14 @@ impl Drop for ActiveClaudeCodeTurnGuard { } } -struct ExternalMcpStopGuard; +struct ExternalMcpStopGuard { + root: PathBuf, + token: String, +} impl Drop for ExternalMcpStopGuard { fn drop(&mut self) { - stop_external_mcp_loopback(); + stop_external_mcp_loopback_for_root(&self.root, &self.token); } } @@ -963,7 +966,10 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let (mcp_url, mcp_token) = start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; direct_turn_trace("claude-mcp-loopback-ready"); - let _mcp_guard = ExternalMcpStopGuard; + let _mcp_guard = ExternalMcpStopGuard { + root: root.to_path_buf(), + token: mcp_token.clone(), + }; let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; std::fs::create_dir_all(&home) .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 11275387c..abb2b4a79 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -35,12 +35,15 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); -static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock< + Mutex>, +> = OnceLock::new(); tokio::task_local! { static EXTERNAL_MCP_BRIDGE_URL: String; } pub(crate) struct ExternalMcpServer { + token: String, _direct_turn_guard: Option, _bridge: super::direct_tool_bridge::DirectToolBridge, task: tokio::task::JoinHandle<()>, @@ -1800,7 +1803,7 @@ async fn start_external_mcp_loopback_with_mode( }; let state = ExternalMcpHttpState { bridge_url: bridge.url().to_string(), - root, + root: root.clone(), token: token.clone(), session_user_id: session.user_id, session_identity_generation: session.identity_generation, @@ -1814,25 +1817,40 @@ async fn start_external_mcp_loopback_with_mode( let _ = axum::serve(listener, app).await; }); let url = format!("http://127.0.0.1:{}{route}", address.port()); - let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let registry = + EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(std::collections::BTreeMap::new())); let mut guard = registry .lock() .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; - if let Some(previous) = guard.take() { - drop(previous); - } - *guard = Some(ExternalMcpServer { - _direct_turn_guard: direct_turn_guard, - _bridge: bridge, - task, - }); + guard.insert( + root, + ExternalMcpServer { + token: token.clone(), + _direct_turn_guard: direct_turn_guard, + _bridge: bridge, + task, + }, + ); Ok((url, token)) } pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { if let Ok(mut guard) = registry.lock() { - guard.take(); + guard.clear(); + } + } +} + +pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { + let Ok(root) = root.canonicalize() else { + return; + }; + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 05bbc90c6..93b3ba4c2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -96,9 +96,7 @@ function directModelCallText(kind: ModelCallKind | null | undefined): string { case 'upstreamFailed': return ( (kind.native ? directNativeKindText(kind.native) : null) ?? - (kind.statusCode >= 500 - ? '智能服务暂时不可用,请稍后重试' - : '智能创作请求无效,请稍后重试') + directUpstreamStatusText(kind.statusCode) ); case 'paidCreditsInsufficient': return '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。'; @@ -113,6 +111,23 @@ function directModelCallText(kind: ModelCallKind | null | undefined): string { } } +function directUpstreamStatusText(statusCode: number): string { + switch (statusCode) { + case 401: + return '上游服务拒绝认证(HTTP 401),请重新登录后重试'; + case 403: + return '上游服务拒绝访问(HTTP 403),请检查账号权限后重试'; + case 408: + return '上游请求超时(HTTP 408),请稍后重试'; + case 429: + return '上游服务繁忙(HTTP 429),请稍后重试'; + default: + return statusCode >= 500 + ? `智能服务暂时不可用(HTTP ${statusCode}),请稍后重试` + : `智能创作请求被上游拒绝(HTTP ${statusCode}),请检查请求后重试`; + } +} + function directTurnDeadlineText(deadline: Deadline): string { switch (deadline) { case 'response-idle': diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts new file mode 100644 index 000000000..2eb77fcc3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { directTurnFailureNoticeText } from '../src/view/project-development/chat/conversation/directTurnFailure'; + +describe('DirectProject 上游失败文案', () => { + it.each([401, 403, 408, 429, 500, 502, 503, 504])( + '保留 HTTP %s 的具体原因', + (statusCode) => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { type: 'upstreamFailed', statusCode, native: null }, + detail: `上游 HTTP ${statusCode}`, + }); + + expect(text).toContain(`HTTP ${statusCode}`); + expect(text).not.toContain('智能创作请求无效'); + }, + ); + + it('原生分类优先于 HTTP 状态文案', () => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { + type: 'upstreamFailed', + statusCode: 429, + native: { type: 'context-window-exceeded' }, + }, + detail: 'upstream', + }); + + expect(text).toContain('模型上下文已超限'); + expect(text).not.toContain('HTTP 429'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 67ff83da2..beca4a1a9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6290,7 +6290,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 - **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 - **根因 7(内部 Claude MCP 桥误用外部只读模式)**:Claude Code sidecar 使用的 loopback MCP 原本调用 `start_external_client_tool_bridge(..., false)`,桥状态 `direct_turn_execution=false` 且没有 `begin_user_turn()` 授权;`agc_register_delivery_contract`、`agc_delivery_status`、`agc_update_plan` 每次都会返回 `ToolRequiresDirectTurn`,模型收到错误后又重复注册计划,最终陷入反馈死循环直到超时。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 8(MCP 全局单槽位)**:外部 MCP 注册表原本只有一个 `Option`,不同项目的 Claude 回合会互相 abort;一个回合结束时的全局 stop 还可能误停另一个项目的桥。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。MCP 注册表按 canonical project root 分桶,停止操作再核对 server token,迟到的旧回合不能误停同项目的新桥。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 - **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。 From 1c4073b500eec6a8370857127938dd8057fbf14e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:29:58 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E6=B5=8B=E8=AF=95=E7=9A=84=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A1=A5=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件宿主开关测试使用外部桥,避免无回合执行许可掩盖插件禁用结果 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index abb2b4a79..28aded7d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -2223,10 +2223,13 @@ mod tests { .unwrap(); std::fs::create_dir(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for enabled in [false, true, false, true] { crate::builtin_plugins::set_enabled( crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, @@ -2324,10 +2327,13 @@ mod tests { let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-"); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for (cocos_enabled, unity_enabled) in [(false, false), (true, false), (false, true), (true, true)] { @@ -2462,10 +2468,13 @@ mod tests { let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-"); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for enabled in [false, true, false, true] { crate::builtin_plugins::set_enabled( crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,