diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 24e7fe815..a528574d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -1,4566 +1,92 @@ use super::*; -pub(crate) fn write_local_game_draft_at( - root: &Path, - prompt: &str, - draft: &LlmGameDraft, -) -> Result { - let prompt = prompt.trim(); - if prompt.is_empty() { - return Err("创作想法不能为空".to_string()); - } - validate_llm_game_draft(prompt, draft)?; - init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; - let checkpoint = create_local_project_checkpoint_at(root)?; - let timestamp = unix_timestamp(); - let title = draft.title.trim(); - let handoff_summary = draft.handoff_summary.trim(); - let design_path = root.join("game/game_design.md"); - let balance_path = root.join("game/balance.json"); - let art_manifest_path = root.join("assets/manifest.art.json"); - let audio_manifest_path = root.join("assets/manifest.audio.json"); - let publish_readme_path = root.join("exports/README.md"); - let agent_log_path = root.join(".agent/logs/agent.log"); - let short_memory_path = root.join("memory/session.md"); - let long_memory_path = root.join("memory/project.md"); - let game_index_path = root.join("game/index.html"); - - append_markdown_entry( - &short_memory_path, - "# 短期记忆\n\n", - &format!("- {timestamp}: {prompt}\n"), - "写入短期记忆失败", - )?; - append_markdown_entry( - &long_memory_path, - "# 项目长期记忆\n\n## 当前约束\n\n- Web 小游戏原型\n- 本地 HTTP 预览\n\n## 创作目标记录\n\n", - &format!("- {timestamp}: {prompt}\n"), - "写入长期记忆失败", - )?; - fs::write( - &design_path, - format!( - "# 游戏设计草案\n\n## 原始想法\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n## LLM 生成草案\n\n{}\n", - draft.design_markdown.trim() - ), - ) - .map_err(|error| format!("写入游戏设计失败:{}: {error}", design_path.display()))?; - fs::write( - &balance_path, - serde_json::to_string_pretty(&draft.balance) - .map_err(|error| format!("生成数值配置失败:{error}"))?, - ) - .map_err(|error| format!("写入数值配置失败:{}: {error}", balance_path.display()))?; - fs::write( - &art_manifest_path, - serde_json::to_string_pretty(&draft.art_manifest) - .map_err(|error| format!("生成美术清单失败:{error}"))?, - ) - .map_err(|error| format!("写入美术清单失败:{}: {error}", art_manifest_path.display()))?; - fs::write( - &audio_manifest_path, - serde_json::to_string_pretty(&draft.audio_manifest) - .map_err(|error| format!("生成音乐音效清单失败:{error}"))?, - ) - .map_err(|error| { - format!( - "写入音乐音效清单失败:{}: {error}", - audio_manifest_path.display() - ) - })?; - fs::write( - &publish_readme_path, - format!( - "# 发布包装草案\n\n## 标题\n\n{title}\n\n## 简介\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n{}\n", - draft.publish_readme.trim() - ), - ) - .map_err(|error| { - format!( - "写入发布包装草案失败:{}: {error}", - publish_readme_path.display() - ) - })?; - - fs::write(&game_index_path, draft.game_html.trim()) - .map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?; - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&agent_log_path) - .and_then(|mut file| { - file.write_all( - format!("{timestamp} game.generate_draft llm\n{handoff_summary}\n").as_bytes(), - ) - }) - .map_err(|error| format!("写入 Agent 日志失败:{}: {error}", agent_log_path.display()))?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "game.generate_draft", - "goal": prompt, - "title": title, - "paths": [ - "memory/session.md", - "memory/project.md", - "game/game_design.md", - "game/balance.json", - "assets/manifest.art.json", - "assets/manifest.audio.json", - "exports/README.md", - "game/index.html", - ], - }), - )?; - let manifest = record_draft_task_progress(root, prompt, timestamp, &agent_log_path)?; - let diff = diff_local_project_checkpoint_at(root, &checkpoint.checkpoint_id)?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "project.diff", - "checkpointId": checkpoint.checkpoint_id, - "added": diff.added.len(), - "changed": diff.changed.len(), - "deleted": diff.deleted.len(), - }), - )?; - - Ok(GenerateLocalGameDraftResult { - project_path: root.to_string_lossy().into_owned(), - game_index_path: game_index_path.to_string_lossy().into_owned(), - design_path: design_path.to_string_lossy().into_owned(), - short_memory_path: short_memory_path.to_string_lossy().into_owned(), - long_memory_path: long_memory_path.to_string_lossy().into_owned(), - manifest, - }) -} - -pub(crate) struct AgentProgressEmitter<'a> { - app: &'a tauri::AppHandle, - project_path: String, -} - -impl<'a> AgentProgressEmitter<'a> { - pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self { - Self { - app, - project_path: project_path.to_string(), - } - } - - fn emit(&self, stage: &str, message: &str) { - let _ = self.app.emit( - "game-creator-agent-progress", - GameCreatorAgentProgressEvent { - project_path: self.project_path.clone(), - stage: stage.to_string(), - message: message.to_string(), - }, - ); - } -} - -pub(crate) fn emit_agent_progress( - progress: Option<&AgentProgressEmitter<'_>>, - stage: &str, - message: &str, -) { - if let Some(progress) = progress { - progress.emit(stage, message); - } -} - +mod canvas_generation; +mod draft_validation; +mod draft_writer; +mod loop_orchestration; +mod pass_artifacts; +mod prompt_context; +mod role_briefs; +mod run_lifecycle; #[cfg(test)] -pub(crate) async fn request_llm_game_draft_with_client( - client: &LlmClient, - prompt: &str, - short_memory: &str, - long_memory: &str, -) -> Result { - let llm = GameCreatorLlmConfig::default(); - request_generator_game_draft_with_client( - client, - &llm, - prompt, - short_memory, - long_memory, - "", - "", - "", - "", - "", - None, - ) - .await -} - -pub(crate) async fn run_game_creator_agent_loop_at( - root: &Path, - app_config: &GameCreatorAppConfig, - prompt: &str, - short_memory: &str, - long_memory: &str, - project_blackboard: &str, - progress: Option<&AgentProgressEmitter<'_>>, -) -> Result { - let spec_path = root.join(".agent/spec.md"); - let findings_path = root.join(".agent/findings.md"); - let run_id = format!("game-generate-draft-{}", unix_millis()); - let mut steps = Vec::new(); - let planner_llm = resolve_game_creator_llm_config_for_agent(app_config, "planner"); - let planner_client = - build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?; - let planner_spec = request_planner_spec_with_client( - &planner_client, - &planner_llm, - prompt, - short_memory, - long_memory, - project_blackboard, - ) - .await - .map(|spec| render_planner_spec(prompt, &spec))?; - fs::write(&spec_path, &planner_spec) - .map_err(|error| format!("写入 Planner 规格失败:{}: {error}", spec_path.display()))?; - emit_agent_progress(progress, "llm.planner.done", "Planner 规格已生成"); - steps.push(with_task_context( - agent_trace_step( - 0, - "Planner", - "completed", - &[ - "memory/session.md", - "memory/project.md", - PROJECT_BLACKBOARD_MEMORY_PATH, - ".agent/conversations/project.jsonl", - ".agent/conversations/agents/", - ".agent/manifest.json", - ], - &[".agent/spec.md"], - "完成玩法规格和专业组分工", - "llm.chat.planner", - ), - "design", - "Director", - Some("design-director"), - "planning", - )); - - let mut latest_findings = - render_evaluator_findings(0, &["暂无上一轮问题,Generator 可开始首轮实现。"]); - fs::write(&findings_path, &latest_findings).map_err(|error| { - format!( - "写入 Evaluator 结果失败:{}: {error}", - findings_path.display() - ) - })?; - steps.push(agent_trace_step( - 0, - "Evaluator", - "waiting", - &[], - &[".agent/findings.md"], - "初始化评估反馈文件", - "file.write.findings", - )); - write_agent_run_trace(root, &run_id, prompt, "running", 0, &steps, None)?; - - let mut last_error = "Evaluator 未产出可用结果".to_string(); - for pass in 1..=GAME_CREATOR_AGENT_LOOP_MAX_PASSES { - emit_agent_progress( - progress, - "agent.orchestrator", - &format!("Orchestrator 正在规划第 {pass} 轮任务图"), - ); - let spec_markdown = read_optional_text(&spec_path)?; - let findings_markdown = read_optional_text(&findings_path)?; - let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; - let agenda = write_agent_pass_agenda(root, pass, &findings_markdown)?; - steps.push(agent_trace_step_owned( - pass, - "Orchestrator", - "completed", - vec![ - ".agent/spec.md".to_string(), - ".agent/findings.md".to_string(), - PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), - ".agent/manifest.json".to_string(), - ], - vec![ - agenda.relative_path.clone(), - agenda.task_graph_relative_path.clone(), - ], - &format!( - "{};waves={};repairFocus={};repairRoutes={};carried={}", - agenda.summary, - agenda.dependency_waves.len(), - agenda.repair_focus.len(), - agenda.repair_routes.len(), - agenda.carried_task_ids.len() - ), - "agent.task_graph.plan_pass", - )); - let group_briefs = match request_agent_group_briefs_with_client( - root, - app_config, - prompt, - short_memory, - long_memory, - &project_blackboard, - &spec_markdown, - &findings_markdown, - &agenda, - &run_id, - pass, - ) - .await - { - Ok(briefs) => briefs, - Err(error) => { - let issues = vec![format!("专业组协作输出不可用:{error}")]; - steps.push(agent_trace_step( - pass, - "专业组协作", - "failed", - &[".agent/spec.md", ".agent/findings.md"], - &[], - &issues[0], - "agent.role.brief", - )); - latest_findings = render_evaluator_findings(pass, &issues); - fs::write(&findings_path, &latest_findings).map_err(|write_error| { - format!( - "写入 Evaluator 结果失败:{}: {write_error}", - findings_path.display() - ) - })?; - steps.push(with_task_context( - agent_trace_step( - pass, - "Evaluator", - "needs-revision", - &[], - &[".agent/findings.md"], - "记录专业组协作失败原因", - "file.write.findings", - ), - "code", - "Review", - Some("quality-review"), - "evaluation", - )); - last_error = issues.join(";"); - write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; - continue; - } - }; - emit_agent_progress( - progress, - "agent.role_briefs", - &format!("6 组角色 brief 已完成,第 {pass} 轮交给 Generator"), - ); - let platform_art_step = - maybe_generate_platform_art_asset_step(root, prompt, &group_briefs, pass, progress) - .await; - append_group_brief_steps(root, pass, &agenda.relative_path, &group_briefs, &mut steps); - if let Some(step) = platform_art_step { - steps.push(step); - } - let group_briefs_markdown = append_prompt_context( - &render_local_conversation_prompt_context(root, Some("*"))?, - &render_agent_group_briefs_context(&group_briefs), - ); - let group_briefs_markdown = append_prompt_context( - &group_briefs_markdown, - &render_local_asset_prompt_context(root)?, - ); - emit_agent_progress( - progress, - "llm.generator", - &format!("Generator 正在调用 LLM 生成第 {pass} 轮可运行草案"), - ); - let generator_llm = resolve_game_creator_llm_config_for_agent(app_config, "generator"); - let generator_client = - build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?; - match request_generator_game_draft_with_client( - &generator_client, - &generator_llm, - prompt, - short_memory, - long_memory, - &project_blackboard, - &spec_markdown, - &findings_markdown, - &group_briefs_markdown, - &read_optional_text(&root.join(&agenda.relative_path))?, - progress, - ) - .await - { - Ok(draft) => { - emit_agent_progress( - progress, - "llm.generator.done", - &format!("Generator 第 {pass} 轮草案已返回,Evaluator 开始质量评审"), - ); - let pass_artifacts = write_agent_pass_artifacts(root, pass, &draft)?; - let mut generator_input_paths = vec![ - "memory/session.md".to_string(), - "memory/project.md".to_string(), - PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), - ".agent/conversations/project.jsonl".to_string(), - ".agent/conversations/agents/".to_string(), - ".agent/manifest.json".to_string(), - ".agent/spec.md".to_string(), - ".agent/findings.md".to_string(), - agenda.relative_path.clone(), - agenda.task_graph_relative_path.clone(), - ]; - generator_input_paths - .extend(group_briefs.iter().map(|brief| brief.relative_path.clone())); - steps.push(agent_trace_step_owned( - pass, - "Generator", - "completed", - generator_input_paths, - vec![pass_artifacts.draft_json.clone()], - "生成结构化游戏草案", - "llm.chat.generator", - )); - append_collaboration_steps(pass, &draft, &pass_artifacts, &mut steps); - let issues = evaluate_game_draft(prompt, &draft); - latest_findings = render_evaluator_findings(pass, &issues); - fs::write(&findings_path, &latest_findings).map_err(|error| { - format!( - "写入 Evaluator 结果失败:{}: {error}", - findings_path.display() - ) - })?; - steps.push(with_task_context( - agent_trace_step_owned( - pass, - "Evaluator", - if issues.is_empty() { - "passed" - } else { - "needs-revision" - }, - vec![ - pass_artifacts.game_html.clone(), - pass_artifacts.design_markdown.clone(), - pass_artifacts.balance_json.clone(), - pass_artifacts.art_manifest_json.clone(), - pass_artifacts.audio_manifest_json.clone(), - pass_artifacts.publish_readme.clone(), - ], - vec![".agent/findings.md".to_string()], - if issues.is_empty() { - "质量评审通过:玩法、资产、数值、程序和发布包装可进入预览试玩" - } else { - "质量评审发现问题,要求下一轮 Generator 修复" - }, - "evaluator.quality_review", - ), - "code", - "Review", - Some("quality-review"), - "evaluation", - )); - if issues.is_empty() { - emit_agent_progress( - progress, - "evaluator.passed", - &format!("Evaluator 第 {pass} 轮质量评审通过"), - ); - append_agent_success_memories(root, pass, &draft, &group_briefs)?; - write_agent_run_trace(root, &run_id, prompt, "passed", pass, &steps, None)?; - return Ok(GameCreatorAgentLoopResult { - run_id, - draft, - spec_markdown, - findings_markdown: latest_findings, - passes: pass, - steps, - }); - } - last_error = issues.join(";"); - emit_agent_progress( - progress, - "evaluator.needs_revision", - &format!("Evaluator 第 {pass} 轮要求返工:{last_error}"), - ); - write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; - } - Err(error) => { - let issues = vec![format!("Generator 输出不可用:{error}")]; - steps.push(agent_trace_step( - pass, - "Generator", - "failed", - &[".agent/spec.md", ".agent/findings.md"], - &[], - &issues[0], - "llm.chat.generator", - )); - latest_findings = render_evaluator_findings(pass, &issues); - fs::write(&findings_path, &latest_findings).map_err(|write_error| { - format!( - "写入 Evaluator 结果失败:{}: {write_error}", - findings_path.display() - ) - })?; - steps.push(with_task_context( - agent_trace_step( - pass, - "Evaluator", - "needs-revision", - &[], - &[".agent/findings.md"], - "记录 Generator 失败原因", - "file.write.findings", - ), - "code", - "Review", - Some("quality-review"), - "evaluation", - )); - last_error = issues.join(";"); - write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; - } - } - } - - let final_error = format!( - "Agent loop 已重试 {GAME_CREATOR_AGENT_LOOP_MAX_PASSES} 轮但仍未通过 Evaluator:{last_error}" - ); - write_agent_run_trace( - root, - &run_id, - prompt, - "failed", - GAME_CREATOR_AGENT_LOOP_MAX_PASSES, - &steps, - Some(&final_error), - )?; - Err(final_error) -} - -pub(crate) async fn request_planner_spec_with_client( - client: &LlmClient, - llm: &GameCreatorLlmConfig, - prompt: &str, - short_memory: &str, - long_memory: &str, - project_blackboard: &str, -) -> Result { - let request = apply_game_creator_llm_reasoning_effort( - LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_planner_system_prompt()), - LlmMessage::user(game_creator_planner_user_prompt( - prompt, - short_memory, - long_memory, - project_blackboard, - )), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS), - llm, - )?; - let response = request_game_creator_llm_text(client, llm, request) - .await - .map_err(|error| format!("Planner 生成失败:{error}"))?; - let spec = strip_llm_thinking_blocks(response.text.as_str()); - if spec.is_empty() { - Err("Planner 未返回规格".to_string()) - } else { - Ok(spec) - } -} - -pub(crate) async fn request_generator_game_draft_with_client( - client: &LlmClient, - llm: &GameCreatorLlmConfig, - prompt: &str, - short_memory: &str, - long_memory: &str, - project_blackboard: &str, - spec_markdown: &str, - findings_markdown: &str, - group_briefs_markdown: &str, - agenda_markdown: &str, - progress: Option<&AgentProgressEmitter<'_>>, -) -> Result { - let system_prompt = game_creator_system_prompt(); - let user_prompt = game_creator_generator_user_prompt( - prompt, - short_memory, - long_memory, - project_blackboard, - spec_markdown, - findings_markdown, - group_briefs_markdown, - agenda_markdown, - ); - // deepseek-v4-pro 等推理模型偶发返回空 content(HTTP 200、completion_tokens=0, - // 参见 DeepSeek-V3 issue #1453)。这类空返回是上游瞬时故障,原样重发通常即可恢复, - // 因此仅对 EmptyResponse 最多重试 3 次(含首次共 4 次请求),其它错误不重试。 - const MAX_EMPTY_RETRIES: u32 = 3; - let mut empty_retries = 0u32; - let response = loop { - let request = apply_game_creator_llm_reasoning_effort( - LlmRunRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt.clone()), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS), - llm, - )?; - match request_game_creator_llm_text(client, llm, request).await { - Ok(response) => break response, - Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { - empty_retries += 1; - eprintln!( - "llm.chat.generator.empty-response 重试 {empty_retries}/{MAX_EMPTY_RETRIES}(上游返回空 content,原样重发)" - ); - // 同步推送到 App 进度面板,便于在界面上看到重试(无需盯命令行)。 - emit_agent_progress( - progress, - "llm.generator.empty_retry", - &format!( - "Generator 收到空返回(DeepSeek 偶发),正在自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}" - ), - ); - continue; - } - Err(error) => { - // 调用失败(含空返回重试耗尽)时,把本次输入连同错误一并落盘,便于复现定位(仅 debug 构建)。 - #[cfg(all(debug_assertions, not(test)))] - debug::persist_error_input(system_prompt, user_prompt.as_str(), &error.to_string()); - return Err(format!("LLM 生成失败:{error}")); - } - } - }; - // 先把原始返回落盘,再解析;解析失败(如输出截断)时仍能从仓库里拿到完整原文(仅 debug 构建)。 - #[cfg(all(debug_assertions, not(test)))] - debug::persist_snapshot(response.text.as_str()); - let content = strip_llm_thinking_blocks(response.text.as_str()); - parse_llm_game_draft_response(content.as_str()) -} - -pub(crate) async fn request_game_creator_llm_text( - client: &LlmClient, - llm: &GameCreatorLlmConfig, - request: LlmRunRequest, -) -> Result { - if llm.stream { - client.stream_run(request, |_| {}).await - } else { - client.run(request).await - } -} - -pub(super) fn build_game_creator_agent_runtime_llm_client( - llm: &GameCreatorLlmConfig, - config_path: &str, -) -> Result { - let mut single_attempt = llm.clone(); - single_attempt.max_retries = 0; - build_game_creator_llm_client_without_redirects_from_llm_config(&single_attempt, config_path) -} - -pub(crate) fn game_creator_agent_llm_error_public_summary( - error: &platform_llm::LlmError, -) -> String { - let kind = match error { - platform_llm::LlmError::Timeout { .. } => "timeout".to_string(), - platform_llm::LlmError::Connectivity { .. } => "connectivity".to_string(), - platform_llm::LlmError::Transport(_) => "transport".to_string(), - platform_llm::LlmError::Upstream { status_code, .. } => { - format!("upstream-{status_code}") - } - platform_llm::LlmError::InvalidConfig(_) => "invalid-config".to_string(), - platform_llm::LlmError::InvalidRequest(_) => "invalid-request".to_string(), - platform_llm::LlmError::StreamUnavailable => "stream-unavailable".to_string(), - platform_llm::LlmError::EmptyResponse => "empty-response".to_string(), - platform_llm::LlmError::Deserialize(_) => "deserialize".to_string(), - }; - let raw = error.to_string(); - format!( - "kind={kind} fingerprint={:x} chars={}", - Sha256::digest(raw.as_bytes()), - raw.chars().count() - ) -} - -pub(crate) async fn request_agent_group_briefs_with_client( - root: &Path, - app_config: &GameCreatorAppConfig, - prompt: &str, - short_memory: &str, - long_memory: &str, - project_blackboard: &str, - spec_markdown: &str, - findings_markdown: &str, - agenda: &AgentPassAgenda, - run_id: &str, - pass: u8, -) -> Result, String> { - let mut role_briefs_by_task = BTreeMap::::new(); - let mut completed_group_context = String::new(); - let mut completed_role_context = String::new(); - let agenda_markdown = read_optional_text(&root.join(&agenda.relative_path))?; - - let ordered_roles = ordered_game_creator_agent_roles(); - let mut waves = if agenda.dependency_waves.is_empty() { - vec![ordered_roles - .iter() - .map(|(_, role)| role.task_id.to_string()) - .collect::>()] - } else { - agenda.dependency_waves.clone() - }; - let known_wave_task_ids = waves - .iter() - .flat_map(|wave| wave.iter()) - .cloned() - .collect::>(); - let missing_task_ids = ordered_roles - .iter() - .map(|(_, role)| role.task_id.to_string()) - .filter(|task_id| !known_wave_task_ids.contains(task_id)) - .collect::>(); - if !missing_task_ids.is_empty() { - waves.push(missing_task_ids); - } - - for wave in waves { - let mut role_brief_jobs = tokio::task::JoinSet::new(); - for task_id in wave { - let Some((definition, role_definition)) = game_creator_agent_role_definition(&task_id) - else { - continue; - }; - let agent_memory_relative_path = - agent_role_memory_relative_path(*definition, *role_definition); - let should_run = agenda - .active_task_ids - .iter() - .any(|active_task_id| active_task_id == role_definition.task_id); - if !should_run { - if let Some((source_path, source_markdown)) = - read_previous_agent_role_brief(root, pass, *definition, *role_definition)? - { - let runtime = start_game_creator_agent_runtime_task_at( - root, - role_definition.task_id, - &format!( - "pass {pass} 沿用 {} / {} brief", - definition.label, role_definition.role - ), - run_id, - "generate-draft", - "沿用上一轮角色 brief", - vec![ - "读取上一轮角色 brief".to_string(), - "写入本轮 carry-over brief".to_string(), - "把 carry-over 状态同步到 Agent Runtime".to_string(), - ], - )?; - let markdown = render_carryover_role_brief( - *definition, - *role_definition, - &source_path, - &source_markdown, - ); - let relative_path = write_agent_role_brief( - root, - pass, - *definition, - *role_definition, - &markdown, - )?; - let role_brief = AgentRoleBrief { - group_definition: *definition, - role_definition: *role_definition, - markdown, - relative_path, - memory_relative_path: agent_memory_relative_path, - status: "carried-over".to_string(), - tool_id: format!( - "agent.task_graph.carryover.{}.{}", - definition.id, role_definition.id - ), - summary: format!( - "沿用上一轮 {} / {} brief,未命中本轮修复范围", - definition.label, role_definition.role - ), - }; - let _ = finish_game_creator_agent_runtime_turn_at( - root, - runtime, - &role_brief.summary, - )?; - completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); - role_briefs_by_task.insert(role_definition.task_id.to_string(), role_brief); - continue; - } - } - let root = root.to_path_buf(); - let app_config = app_config.clone(); - let prompt = prompt.to_string(); - let short_memory = short_memory.to_string(); - let long_memory = long_memory.to_string(); - let project_blackboard = project_blackboard.to_string(); - let spec_markdown = spec_markdown.to_string(); - let findings_markdown = findings_markdown.to_string(); - let agenda_markdown = agenda_markdown.clone(); - let completed_group_context = completed_group_context.clone(); - let completed_role_context = completed_role_context.clone(); - let run_id = run_id.to_string(); - role_brief_jobs.spawn(async move { - build_agent_role_brief_draft( - root, - app_config, - *definition, - *role_definition, - prompt, - short_memory, - long_memory, - project_blackboard, - spec_markdown, - findings_markdown, - agenda_markdown, - completed_group_context, - completed_role_context, - run_id, - pass, - ) - .await - }); - } - let mut role_brief_drafts = Vec::new(); - while let Some(result) = role_brief_jobs.join_next().await { - let draft = result.map_err(|error| format!("角色 Agent 并行任务失败:{error}"))??; - role_brief_drafts.push(draft); - } - role_brief_drafts.sort_by_key(|draft| { - ordered_roles - .iter() - .position(|(_, role)| role.task_id == draft.role_definition.task_id) - .unwrap_or(usize::MAX) - }); - for draft in role_brief_drafts { - let relative_path = write_agent_role_brief( - root, - pass, - draft.group_definition, - draft.role_definition, - &draft.markdown, - )?; - let role_brief = AgentRoleBrief { - group_definition: draft.group_definition, - role_definition: draft.role_definition, - markdown: draft.markdown, - relative_path, - memory_relative_path: draft.memory_relative_path, - status: draft.status, - tool_id: draft.tool_id, - summary: draft.summary, - }; - completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); - role_briefs_by_task.insert(role_brief.role_definition.task_id.to_string(), role_brief); - } - completed_group_context = render_completed_agent_group_context(&role_briefs_by_task); - } - - let mut briefs = Vec::new(); - for definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - let role_briefs = definition - .roles - .iter() - .filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned()) - .collect::>(); - let markdown = render_agent_group_brief_markdown(&role_briefs); - let relative_path = write_agent_group_brief(root, pass, definition, &markdown)?; - briefs.push(AgentGroupBrief { - definition, - markdown, - relative_path, - role_briefs, - }); - } - Ok(briefs) -} - -#[derive(Debug)] -struct AgentRoleBriefDraft { - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, - markdown: String, - memory_relative_path: String, - status: String, - tool_id: String, - summary: String, -} - -fn ordered_game_creator_agent_roles( -) -> Vec<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> { - GAME_CREATOR_AGENT_GROUP_DEFINITIONS - .iter() - .flat_map(|group_definition| { - group_definition - .roles - .iter() - .map(move |role_definition| (group_definition, role_definition)) - }) - .collect() -} - -fn render_completed_agent_group_context( - role_briefs_by_task: &BTreeMap, -) -> String { - let mut output = String::new(); - for group_definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - let role_briefs = group_definition - .roles - .iter() - .filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned()) - .collect::>(); - if role_briefs.is_empty() { - continue; - } - output.push_str(&format!( - "## {} / {}\n\n{}\n\n", - group_definition.label, - group_definition.role, - render_agent_group_brief_markdown(&role_briefs).trim() - )); - } - output -} - -#[allow(clippy::too_many_arguments)] -async fn build_agent_role_brief_draft( - root: PathBuf, - app_config: GameCreatorAppConfig, - definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, - prompt: String, - short_memory: String, - long_memory: String, - project_blackboard: String, - spec_markdown: String, - findings_markdown: String, - agenda_markdown: String, - completed_group_context: String, - completed_role_context: String, - run_id: String, - pass: u8, -) -> Result { - let runtime = start_game_creator_agent_runtime_task_at( - &root, - role_definition.task_id, - &format!( - "pass {pass} 生成 {} / {} brief", - definition.label, role_definition.role - ), - &run_id, - "generate-draft", - "读取角色上下文", - vec![ - "读取项目记忆、黑板、Agent 私有记忆和最近对话".to_string(), - "根据 Orchestrator agenda 生成本角色 brief".to_string(), - "把角色 brief 写入本轮 pass 产物并同步 runtime 状态".to_string(), - ], - )?; - let result: Result = async { - let agent_memory_relative_path = - agent_role_memory_relative_path(definition, role_definition); - let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; - let agent_conversation_context = - render_local_conversation_prompt_context(&root, Some(role_definition.task_id))?; - let role_short_memory = append_prompt_context(&agent_conversation_context, &short_memory); - let local_markdown = render_local_agent_role_brief( - definition, - role_definition, - &prompt, - &role_short_memory, - &long_memory, - &project_blackboard, - &agent_memory, - &spec_markdown, - &findings_markdown, - &agenda_markdown, - &completed_group_context, - &completed_role_context, - pass, - ); - let runtime = advance_game_creator_agent_runtime_turn_at( - &root, - runtime, - "brief", - if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { - "调用角色专属 LLM 生成 brief" - } else { - "使用本地编排生成 brief" - }, - "角色上下文已读取。", - )?; - let (markdown, tool_id, summary) = - if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { - let markdown = request_agent_role_brief_with_config( - &app_config, - role_definition.task_id, - &local_markdown, - ) - .await?; - ( - markdown, - format!("llm.chat.{}", role_definition.task_id), - format!( - "{} / {} 使用 agentLlm.{} 生成 brief", - definition.label, role_definition.role, role_definition.task_id - ), - ) - } else { - ( - local_markdown, - role_definition.tool_id.to_string(), - format!( - "本地编排生成 {} / {} brief", - definition.label, role_definition.role - ), - ) - }; - let draft = AgentRoleBriefDraft { - group_definition: definition, - role_definition, - markdown, - memory_relative_path: agent_memory_relative_path, - status: "completed".to_string(), - tool_id, - summary, - }; - finish_game_creator_agent_runtime_turn_at(&root, runtime, &draft.summary)?; - Ok::(draft) - } - .await; - if let Err(error) = &result { - let failed_state = read_game_creator_agent_runtime_at(&root, role_definition.task_id) - .map(|result| result.state); - if let Ok(state) = failed_state { - let _ = fail_game_creator_agent_runtime_turn_at(&root, state, error); - } - } - result -} - -pub(crate) fn has_game_creator_agent_llm_override( - config: &GameCreatorAppConfig, - agent_id: &str, -) -> bool { - config - .agent_llm - .get(agent_id) - .is_some_and(|patch| !is_empty_game_creator_llm_patch(patch)) -} - -pub(crate) async fn request_agent_role_brief_with_config( - config: &GameCreatorAppConfig, - agent_id: &str, - local_markdown: &str, -) -> Result { - let llm = resolve_game_creator_llm_config_for_agent(config, agent_id); - let config_path = format!("agentLlm.{agent_id}"); - let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; - let request = apply_game_creator_llm_reasoning_effort( - LlmRunRequest::new(vec![ - LlmMessage::system(game_creator_role_agent_system_prompt()), - LlmMessage::user(format!( - "请基于下面的本地上下文生成本角色的 Markdown brief。只返回 brief 正文,不要代码块。\n\n{}", - truncate_prompt_context(local_markdown) - )), - ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_max_output_tokens(GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS), - &llm, - )?; - let response = request_game_creator_llm_text(&client, &llm, request) - .await - .map_err(|error| format!("{config_path} 生成角色 brief 失败:{error}"))?; - let brief = strip_llm_thinking_blocks(response.text.as_str()); - if brief.is_empty() { - Err(format!("{config_path} 未返回角色 brief")) - } else { - Ok(brief) - } -} - -pub(crate) fn game_creator_role_agent_system_prompt() -> &'static str { - "你是 AI 游戏创作多智能体中的一个专业角色 agent。输出必须是简洁、可执行的 Markdown brief,服务于后续 Generator 生成可试玩 Web 小游戏原型。不要泄露密钥,不要输出 JSON,不要包裹代码块。" -} - -pub(crate) fn render_local_agent_role_brief( - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, - prompt: &str, - short_memory: &str, - long_memory: &str, - project_blackboard: &str, - agent_memory: &str, - spec_markdown: &str, - findings_markdown: &str, - agenda_markdown: &str, - completed_group_context: &str, - completed_role_context: &str, - pass: u8, -) -> String { - format!( - "本角色判断:pass {pass},{} / {} 负责 {},围绕用户需求“{}”给 Generator 提供约束。\n交付物:{}\n下游约束:{}\n验收风险:{}\n\n## Planner 摘要\n\n{}\n\n## Evaluator 反馈\n\n{}\n\n## 本轮 agenda\n\n{}\n\n## 记忆摘要\n\n短期:{}\n长期:{}\n项目黑板:{}\n本角色私有记忆:{}\n\n## 已完成上下文\n\n{}\n{}", - group_definition.label, - role_definition.role, - role_definition.task_id, - truncate_inline(prompt, 80), - role_definition.brief_path_name, - local_role_downstream_constraint(group_definition, role_definition), - local_role_acceptance_risk(group_definition, role_definition), - truncate_prompt_context(spec_markdown), - truncate_prompt_context(findings_markdown), - truncate_prompt_context(agenda_markdown), - truncate_inline(short_memory, 360), - truncate_inline(long_memory, 120), - truncate_inline(project_blackboard, 160), - truncate_inline(agent_memory, 160), - truncate_prompt_context(completed_group_context), - truncate_prompt_context(completed_role_context) - ) -} - -pub(crate) fn local_role_downstream_constraint( - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, -) -> &'static str { - match (group_definition.id, role_definition.id) { - ("design", _) => "Generator 必须保留核心循环、输入、目标、失败、胜利和重开路径。", - ("balance", _) => "Generator 必须输出可被 game/balance.json 表达的速度、生命、难度和节奏参数。", - ("art", "asset") => "Generator 必须保留画板资源占位引用,并给出本地 fallback 视觉。", - ("art", _) => "Generator 必须保持像素风厨房主题和首版可读性。", - ("audio", _) => "Generator 必须给出 BGM 与核心交互音效清单,缺素材时使用静音 fallback。", - ("code", _) => "Generator 必须生成单文件 canvas HTML、输入监听、requestAnimationFrame 主循环和静态自检可通过的代码。", - ("publishing", _) => "Generator 必须输出标题、简介、标签、封面需求和试玩验收说明。", - _ => "Generator 必须响应本角色交付物并保持可试玩原型闭环。", - } -} - -pub(crate) fn local_role_acceptance_risk( - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, -) -> &'static str { - match (group_definition.id, role_definition.id) { - ("code", _) => { - "缺 canvas 绘制、输入监听、胜负状态、重开或使用远程资源都会触发 Evaluator 返工。" - } - ("art", "asset") | ("audio", "sfx") => "未记录画板或本地资产占位会影响资产回流验收。", - ("publishing", _) => "缺发布包装会影响最终 exports/README.md 与运营组 handoff。", - _ => "输出空泛或偏离用户需求会增加 Generator 返工概率。", - } -} - -pub(crate) fn truncate_inline(value: &str, max_chars: usize) -> String { - let trimmed = value.split_whitespace().collect::>().join(" "); - let mut output = trimmed.chars().take(max_chars).collect::(); - if trimmed.chars().count() > max_chars { - output.push_str("..."); - } - output -} - -pub(crate) fn evaluate_game_draft(prompt: &str, draft: &LlmGameDraft) -> Vec { - let mut issues = Vec::new(); - if let Err(error) = validate_llm_game_draft(prompt, draft) { - issues.push(error); - } - let html = draft.game_html.to_ascii_lowercase(); - if !["keydown", "keyup", "pointer", "mousedown", "touch", "click"] - .iter() - .any(|needle| html.contains(needle)) - { - issues.push("gameHtml 缺少明确的键盘、鼠标或触摸输入监听".to_string()); - } - issues -} - -pub(crate) fn render_planner_spec(prompt: &str, spec: &str) -> String { - format!( - "# Planner Spec\n\n## 用户需求\n\n{}\n\n## 规格\n\n{}\n", - prompt.trim(), - spec.trim() - ) -} - -pub(crate) fn render_evaluator_findings(pass: u8, issues: &[impl AsRef]) -> String { - let issue_values = issues - .iter() - .map(|issue| issue.as_ref().trim()) - .filter(|issue| !issue.is_empty()) - .collect::>(); - let actionable_issues = issue_values - .iter() - .filter(|issue| !issue.contains("暂无上一轮问题")) - .map(|issue| (*issue).to_string()) - .collect::>(); - let mut output = format!( - "# Evaluator Findings\n\n- pass: {pass}\n- status: {}\n\n", - if issue_values.is_empty() { - "passed" - } else { - "needs-revision" - } - ); - if issue_values.is_empty() { - output - .push_str("## 结果\n\n- 本地静态验收通过:HTML 自包含、包含 canvas、主循环和输入。\n"); - } else { - output.push_str("## 问题\n\n"); - for issue in &issue_values { - output.push_str("- "); - output.push_str(issue); - output.push('\n'); - } - } - output.push_str("\n## Repair Routes\n\n```json\n"); - if actionable_issues.is_empty() { - output.push_str("[]"); - } else { - let routes = build_game_creation_seed_task_graph("AI 游戏创作") - .map(|graph| route_game_creation_repair_issues(&graph, &actionable_issues)) - .unwrap_or_default(); - match serde_json::to_string_pretty(&routes) { - Ok(payload) => output.push_str(&payload), - Err(_) => output.push_str("[]"), - } - } - output.push_str("\n```\n"); - output -} - -pub(crate) fn write_agent_pass_agenda( - root: &Path, - pass: u8, - findings_markdown: &str, -) -> Result { - let graph = build_game_creation_seed_task_graph("AI 游戏创作") - .map_err(|error| format!("构建 Agent 编排任务图失败:{error}"))?; - let pass_plan = plan_game_creation_agent_pass(&graph, pass, findings_markdown); - let repair_routes = pass_plan - .repair_routes - .iter() - .map(|route| GameCreationAgentRepairRouteTrace { - issue: route.issue.clone(), - task_ids: route.task_ids.clone(), - reason: route.reason.clone(), - }) - .collect::>(); - let relative_path = format!(".agent/passes/pass-{pass}/agenda.md"); - let markdown = render_agent_pass_agenda_markdown( - pass, - &pass_plan.mode, - &pass_plan.active_task_ids, - &pass_plan.carried_task_ids, - &pass_plan.dependency_waves, - &pass_plan.repair_focus, - &repair_routes, - ); - write_agent_pass_file(root, &relative_path, &markdown)?; - let task_graph_relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); - write_agent_pass_file( - root, - &task_graph_relative_path, - &render_agent_pass_task_graph_json( - pass, - &pass_plan.mode, - &pass_plan.summary, - &pass_plan.active_task_ids, - &pass_plan.carried_task_ids, - &pass_plan.dependency_waves, - &pass_plan.repair_focus, - &repair_routes, - )?, - )?; - Ok(AgentPassAgenda { - relative_path, - task_graph_relative_path, - active_task_ids: pass_plan.active_task_ids, - carried_task_ids: pass_plan.carried_task_ids, - dependency_waves: pass_plan.dependency_waves, - repair_focus: pass_plan.repair_focus, - repair_routes, - summary: pass_plan.summary, - }) -} - -pub(crate) fn render_agent_pass_agenda_markdown( - pass: u8, - mode: &str, - active_task_ids: &[String], - carried_task_ids: &[String], - dependency_waves: &[Vec], - issues: &[String], - repair_routes: &[GameCreationAgentRepairRouteTrace], -) -> String { - let mut output = format!( - "# Orchestrator Agenda\n\n- pass: {pass}\n- mode: {}\n- activeTasks: {}\n- carriedTasks: {}\n\n", - mode, - active_task_ids.join(", "), - if carried_task_ids.is_empty() { - "none".to_string() - } else { - carried_task_ids.join(", ") - } - ); - output.push_str("## Repair Focus\n\n"); - if issues.is_empty() { - output.push_str("- 首轮生成,所有组内角色参与。\n"); - } else { - for issue in issues { - output.push_str("- "); - output.push_str(issue); - output.push('\n'); - } - } - output.push_str("\n## Dependency Waves\n\n"); - for (index, wave) in dependency_waves.iter().enumerate() { - output.push_str(&format!( - "- wave {}: {}\n", - index + 1, - if wave.is_empty() { - "none".to_string() - } else { - wave.join(", ") - } - )); - } - output.push_str("\n## Repair Routes\n\n"); - if repair_routes.is_empty() { - output.push_str("- none\n"); - } else { - for route in repair_routes { - output.push_str(&format!( - "- issue: {}\n taskIds: {}\n reason: {}\n", - route.issue, - route.task_ids.join(", "), - route.reason - )); - } - } - output.push_str( - "\n## Rule\n\n- activeTasks 产出对应角色 brief。\n- carriedTasks 沿用上一轮 brief,避免无关角色重复返工。\n- dependencyWaves 是按任务依赖排序后的执行层级,Generator 必须优先服从较早 wave 的约束。\n", - ); - output -} - -pub(crate) fn render_agent_pass_task_graph_json( - pass: u8, - mode: &str, - summary: &str, - active_task_ids: &[String], - carried_task_ids: &[String], - dependency_waves: &[Vec], - issues: &[String], - repair_routes: &[GameCreationAgentRepairRouteTrace], -) -> Result { - serde_json::to_string_pretty(&serde_json::json!({ - "schemaVersion": "game-creator-agent-pass-task-graph.v1", - "pass": pass, - "mode": mode, - "summary": summary, - "activeTaskIds": active_task_ids, - "carriedTaskIds": carried_task_ids, - "repairFocus": issues, - "repairRoutes": repair_routes, - "dependencyWaves": dependency_waves, - })) - .map(|payload| format!("{payload}\n")) - .map_err(|error| format!("生成 Agent pass task graph 失败:{error}")) -} - -pub(crate) fn read_previous_agent_role_brief( - root: &Path, - pass: u8, - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, -) -> Result, String> { - if pass <= 1 { - return Ok(None); - } - let relative_path = format!( - ".agent/passes/pass-{}/groups/{}/{}", - pass - 1, - group_definition.id, - role_definition.brief_path_name - ); - match fs::read_to_string(root.join(&relative_path)) { - Ok(content) => Ok(Some((relative_path, content))), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(format!( - "读取上一轮角色 brief 失败:{relative_path}: {error}" - )), - } -} - -pub(crate) fn render_carryover_role_brief( - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, - source_path: &str, - source_markdown: &str, -) -> String { - format!( - "本角色判断:沿用上一轮 {} / {} 输出。\n交付物:{}\n下游约束:本轮未命中该任务,Generator 只在必要时读取此约束。\n验收风险:如果 Evaluator 后续命中该组,下一轮必须重新激活。\n\n## 上一轮 brief\n\n{}", - group_definition.label, - role_definition.role, - source_path, - source_markdown.trim() - ) -} - -pub(crate) fn write_agent_pass_artifacts( - root: &Path, - pass: u8, - draft: &LlmGameDraft, -) -> Result { - let relative_dir = format!(".agent/passes/pass-{pass}"); - let pass_dir = root.join(&relative_dir); - fs::create_dir_all(&pass_dir) - .map_err(|error| format!("创建 Agent pass 目录失败:{}: {error}", pass_dir.display()))?; - - let paths = AgentPassArtifactPaths { - draft_json: format!("{relative_dir}/draft.json"), - design_markdown: format!("{relative_dir}/design.md"), - balance_json: format!("{relative_dir}/balance.json"), - art_manifest_json: format!("{relative_dir}/manifest.art.json"), - audio_manifest_json: format!("{relative_dir}/manifest.audio.json"), - publish_readme: format!("{relative_dir}/README.md"), - game_html: format!("{relative_dir}/game.html"), - handoff_markdown: format!("{relative_dir}/handoff.md"), - }; - - write_agent_pass_file( - root, - &paths.draft_json, - &format!( - "{}\n", - serde_json::to_string_pretty(draft) - .map_err(|error| format!("序列化 Agent pass draft 失败:{error}"))? - ), - )?; - write_agent_pass_file( - root, - &paths.design_markdown, - &format!("# 策划组 / Gameplay\n\n{}\n", draft.design_markdown.trim()), - )?; - write_agent_pass_file( - root, - &paths.balance_json, - &format!( - "{}\n", - serde_json::to_string_pretty(&draft.balance) - .map_err(|error| format!("序列化 Agent pass 数值失败:{error}"))? - ), - )?; - write_agent_pass_file( - root, - &paths.art_manifest_json, - &format!( - "{}\n", - serde_json::to_string_pretty(&draft.art_manifest) - .map_err(|error| format!("序列化 Agent pass 美术清单失败:{error}"))? - ), - )?; - write_agent_pass_file( - root, - &paths.audio_manifest_json, - &format!( - "{}\n", - serde_json::to_string_pretty(&draft.audio_manifest) - .map_err(|error| format!("序列化 Agent pass 音乐清单失败:{error}"))? - ), - )?; - write_agent_pass_file( - root, - &paths.publish_readme, - &format!("# 运营组 / Publish\n\n{}\n", draft.publish_readme.trim()), - )?; - write_agent_pass_file(root, &paths.game_html, &draft.game_html)?; - write_agent_pass_file( - root, - &paths.handoff_markdown, - &render_agent_pass_handoff(pass, draft), - )?; - - Ok(paths) -} - -pub(crate) fn write_agent_pass_file( - root: &Path, - relative_path: &str, - content: &str, -) -> Result<(), String> { - let path = root.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent pass 文件目录失败:{}: {error}", - parent.display() - ) - })?; - } - fs::write(&path, content) - .map_err(|error| format!("写入 Agent pass 文件失败:{}: {error}", path.display())) -} - -pub(crate) fn write_agent_group_brief( - root: &Path, - pass: u8, - definition: AgentGroupDefinition, - markdown: &str, -) -> Result { - let relative_path = format!( - ".agent/passes/pass-{pass}/groups/{}", - definition.brief_path_name - ); - write_agent_pass_file( - root, - &relative_path, - &format!( - "# {} / {}\n\n{}\n", - definition.label, - definition.role, - markdown.trim() - ), - )?; - Ok(relative_path) -} - -pub(crate) fn write_agent_role_brief( - root: &Path, - pass: u8, - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, - markdown: &str, -) -> Result { - let relative_path = format!( - ".agent/passes/pass-{pass}/groups/{}/{}", - group_definition.id, role_definition.brief_path_name - ); - write_agent_pass_file( - root, - &relative_path, - &format!( - "# {} / {}\n\n- task: {}\n\n{}\n", - group_definition.label, - role_definition.role, - role_definition.task_id, - markdown.trim() - ), - )?; - Ok(relative_path) -} - -pub(crate) fn agent_role_memory_relative_path( - group_definition: AgentGroupDefinition, - role_definition: AgentRoleDefinition, -) -> String { - format!( - "memory/agents/{}/{}", - group_definition.id, role_definition.brief_path_name - ) -} - -pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result { - if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string()); - } - for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - for role in group.roles { - if role.task_id == task_id { - return Ok(agent_role_memory_relative_path(group, *role)); - } - } - } - Err(format!("未知 Agent 任务:{task_id}")) -} - -pub(crate) fn append_agent_success_memories( - root: &Path, - pass: u8, - draft: &LlmGameDraft, - briefs: &[AgentGroupBrief], -) -> Result<(), String> { - let timestamp = unix_timestamp(); - let title = draft.title.trim(); - let mut blackboard_entry = format!( - "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n\n## 角色共享摘要\n\n" - ); - - for brief in briefs { - for role_brief in &brief.role_briefs { - blackboard_entry.push_str(&format!( - "- {} / {}:{};status={};brief={}\n", - role_brief.group_definition.label, - role_brief.role_definition.role, - role_brief.summary, - role_brief.status, - role_brief.relative_path - )); - let private_entry = format!( - "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- task:{}\n- status:{}\n- brief:{}\n- 摘要:{}\n", - role_brief.role_definition.task_id, - role_brief.status, - role_brief.relative_path, - role_brief.summary - ); - append_markdown_entry( - &root.join(&role_brief.memory_relative_path), - &format!( - "# Agent 私有记忆 - {} / {}\n\n", - role_brief.group_definition.label, role_brief.role_definition.role - ), - &private_entry, - "写入 Agent 私有记忆失败", - )?; - } - } - - append_markdown_entry( - &root.join(PROJECT_BLACKBOARD_MEMORY_PATH), - "# 项目黑板\n\n", - &blackboard_entry, - "写入项目黑板失败", - ) -} - -pub(crate) fn append_group_brief_steps( - root: &Path, - pass: u8, - agenda_relative_path: &str, - briefs: &[AgentGroupBrief], - steps: &mut Vec, -) { - let canvas_asset_media_types = project_canvas_asset_media_types(root); - for brief in briefs { - for role_brief in &brief.role_briefs { - let input_paths = vec![ - "memory/session.md".to_string(), - "memory/project.md".to_string(), - PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), - ".agent/conversations/project.jsonl".to_string(), - ".agent/conversations/agents/".to_string(), - role_brief.memory_relative_path.clone(), - ".agent/manifest.json".to_string(), - ".agent/spec.md".to_string(), - ".agent/findings.md".to_string(), - agenda_relative_path.to_string(), - ]; - let mut output_paths = vec![role_brief.relative_path.clone()]; - if role_brief.status == "completed" || role_brief.status == "carried-over" { - output_paths.push(role_brief.memory_relative_path.clone()); - output_paths.push(PROJECT_BLACKBOARD_MEMORY_PATH.to_string()); - } - let mut step = with_task_context( - agent_trace_step_owned( - pass, - &format!( - "{} / {}", - role_brief.group_definition.label, role_brief.role_definition.role - ), - &role_brief.status, - input_paths.clone(), - output_paths, - &role_brief.summary, - &role_brief.tool_id, - ), - role_brief.group_definition.id, - role_brief.role_definition.role, - Some(role_brief.role_definition.task_id), - "role-brief", - ); - if let Some(tool_call) = - suggested_canvas_tool_call(role_brief, &input_paths, &canvas_asset_media_types) - { - step.tool_calls.push(tool_call); - } - steps.push(step); - } - let role_paths = brief - .role_briefs - .iter() - .map(|role_brief| role_brief.relative_path.clone()) - .collect::>(); - steps.push(with_task_context( - agent_trace_step_owned( - pass, - &format!("{} / GroupCoordinator", brief.definition.label), - "completed", - role_paths, - vec![brief.relative_path.clone()], - "汇总组内角色 brief,交给 Generator", - &format!("agent.group.aggregate.{}", brief.definition.id), - ), - brief.definition.id, - "GroupCoordinator", - None, - "group-aggregate", - )); - } -} - -pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { - read_manifest_for_project(root) - .map(|manifest| { - manifest - .assets - .iter() - .filter(|asset| asset.source.kind == GameCreationAppAssetSourceKind::Canvas) - .map(|asset| asset.media_type.clone()) - .collect() - }) - .unwrap_or_default() -} - -pub(crate) fn suggested_canvas_tool_call( - role_brief: &AgentRoleBrief, - input_paths: &[String], - canvas_asset_media_types: &[String], -) -> Option { - if role_brief.status != "completed" - || role_has_canvas_assets(role_brief, canvas_asset_media_types) - { - return None; - } - let tool_id = match ( - role_brief.group_definition.id, - role_brief.role_definition.id, - ) { - ("art", "asset") | ("audio", "sfx") => "agent.tool.suggest.canvas.project_sync", - _ => return None, - }; - Some(GameCreationAgentToolCallTrace { - tool_id: tool_id.to_string(), - status: "suggested".to_string(), - input_paths: input_paths.to_vec(), - output_paths: Vec::new(), - summary: "项目还没有对应类型的画板回流素材;建议用户确认 /sync-canvas-project <画板项目ID> 后同步画板资源到本地 assets/。" - .to_string(), - }) -} - -pub(crate) async fn maybe_generate_platform_art_asset_step( - root: &Path, - prompt: &str, - briefs: &[AgentGroupBrief], - pass: u8, - progress: Option<&AgentProgressEmitter<'_>>, -) -> Option { - if !needs_platform_art_asset_generation(root, briefs) || !editor_api_key_is_configured() { - return None; - } - emit_agent_progress( - progress, - "editor.image_generate", - "美术组正在通过平台 External Editor API 生成首版素材", - ); - let result = generate_platform_art_asset_at(root, prompt, briefs).await; - let (status, output_paths, summary) = match result { - Ok(generated) => ( - "completed", - vec![generated.asset.local_path.clone()], - format!( - "已通过平台 External Editor API 生成首版美术素材:{}", - generated.asset.local_path - ), - ), - Err(error) => ( - "failed", - Vec::new(), - format!("平台 External Editor API 生成首版美术素材失败:{error}"), - ), - }; - Some(with_task_context( - agent_trace_step_owned( - pass, - "美术组 / PlatformImageGenerate", - status, - vec![ - ".agent/spec.md".to_string(), - ".agent/manifest.json".to_string(), - ], - output_paths, - &summary, - "agent.tool.platform.editor_image_generate", - ), - "art", - "Asset", - Some("art-asset-plan"), - "asset-generation", - )) -} - -pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGroupBrief]) -> bool { - let canvas_asset_media_types = project_canvas_asset_media_types(root); - briefs.iter().any(|brief| { - brief.role_briefs.iter().any(|role_brief| { - role_brief.status == "completed" - && role_brief.group_definition.id == "art" - && role_brief.role_definition.id == "asset" - && !role_has_canvas_assets(role_brief, &canvas_asset_media_types) - }) - }) -} - -pub(crate) fn editor_api_key_is_configured() -> bool { - load_game_creator_app_config() - .ok() - .and_then(|config| trim_config_string(&config.editor_api.api_key)) - .is_some() -} - -pub(crate) fn role_has_canvas_assets(role_brief: &AgentRoleBrief, media_types: &[String]) -> bool { - match ( - role_brief.group_definition.id, - role_brief.role_definition.id, - ) { - ("art", "asset") => media_types.iter().any(|media_type| { - let media_type = media_type.as_str(); - media_type.starts_with("image/") - || media_type == "application/vnd.genarrative.image-sequence" - }), - ("audio", "sfx") => media_types - .iter() - .any(|media_type| media_type.starts_with("audio/")), - _ => false, - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PlatformArtAssetGenerationOptions { - pub(crate) output_path: Option, - pub(crate) aspect_ratio: String, - pub(crate) image_size: String, - pub(crate) asset_kind: String, - pub(crate) asset_label: String, -} - -impl Default for PlatformArtAssetGenerationOptions { - fn default() -> Self { - Self { - output_path: None, - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "game-art".to_string(), - asset_label: "AI 游戏首版美术素材".to_string(), - } - } -} - -pub(crate) fn prepare_platform_art_asset_output_path( - root: &Path, - output_path: Option<&str>, -) -> Result, String> { - let Some(output_path) = output_path.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - let normalized = normalize_relative_path(output_path)?; - if !normalized.starts_with("assets/") { - return Err("图片生成 outputPath 必须位于项目 assets/ 目录".to_string()); - } - let extension = Path::new(&normalized) - .extension() - .and_then(|value| value.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - if !matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "webp") { - return Err("图片生成 outputPath 只允许 png、jpg、jpeg 或 webp 文件".to_string()); - } - let absolute = resolve_local_project_path(root, &normalized)?; - if absolute.exists() { - return Err(format!( - "图片生成 outputPath 已存在,禁止静默覆盖:{normalized}" - )); - } - Ok(Some((normalized, absolute))) -} - -pub(crate) fn platform_art_asset_output_extension_matches( - output_path: &str, - generated_extension: &str, -) -> bool { - let requested = Path::new(output_path) - .extension() - .and_then(|value| value.to_str()) - .map(str::to_ascii_lowercase) - .unwrap_or_default(); - requested == generated_extension - || matches!( - (requested.as_str(), generated_extension), - ("jpg", "jpeg") | ("jpeg", "jpg") - ) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ExternalCanvasGenerationContext { - project_id: String, - asset_folder_id: String, - canvas_name: String, -} - -fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value { - payload.get("data").unwrap_or(payload) -} - -async fn external_editor_json_request( - request: reqwest::RequestBuilder, - action: &str, -) -> Result { - let response = request - .send() - .await - .map_err(|error| format!("{action}失败:{error}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("{action}失败:HTTP {}", status.as_u16())); - } - response - .json::() - .await - .map_err(|error| format!("解析{action}响应失败:{error}")) -} - -async fn prepare_external_canvas_generation_context( - root: &Path, - client: &reqwest::Client, - api_base_url: &str, - api_key: &str, -) -> Result { - let manifest = read_manifest_for_project(root)?; - let canvas_name = manifest.name.trim().chars().take(80).collect::(); - let canvas_name = if canvas_name.is_empty() { - "未命名游戏原型".to_string() - } else { - canvas_name - }; - let projects_payload = external_editor_json_request( - client - .get(format!("{api_base_url}/api/external/v1/editor/projects")) - .bearer_auth(api_key), - "读取外部画布项目", - ) - .await?; - let projects = external_editor_response_data(&projects_payload) - .get("projects") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "外部画布项目响应缺少 projects".to_string())?; - let project_id = projects - .iter() - .find(|project| json_string_field(project, "title").as_deref() == Some(&canvas_name)) - .and_then(|project| json_string_field(project, "projectId")); - let project_id = match project_id { - Some(project_id) => project_id, - None => { - let payload = external_editor_json_request( - client - .post(format!("{api_base_url}/api/external/v1/editor/projects")) - .bearer_auth(api_key) - .json(&serde_json::json!({ "title": canvas_name })), - "创建外部画布项目", - ) - .await?; - external_editor_response_data(&payload) - .get("project") - .and_then(|project| json_string_field(project, "projectId")) - .ok_or_else(|| "创建外部画布项目响应缺少 projectId".to_string())? - } - }; - - let library_payload = external_editor_json_request( - client - .get(format!( - "{api_base_url}/api/external/v1/editor/assets/library" - )) - .bearer_auth(api_key), - "读取外部素材库", - ) - .await?; - let folders = external_editor_response_data(&library_payload) - .get("library") - .and_then(|library| library.get("folders")) - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "外部素材库响应缺少 library.folders".to_string())?; - let asset_folder_id = folders - .iter() - .find(|folder| json_string_field(folder, "label").as_deref() == Some(&canvas_name)) - .and_then(|folder| json_string_field(folder, "folderId")); - let asset_folder_id = match asset_folder_id { - Some(folder_id) => folder_id, - None => { - let payload = external_editor_json_request( - client - .post(format!( - "{api_base_url}/api/external/v1/editor/assets/folders" - )) - .bearer_auth(api_key) - .json(&serde_json::json!({ "label": canvas_name })), - "创建外部素材库目录", - ) - .await?; - external_editor_response_data(&payload) - .get("folder") - .and_then(|folder| json_string_field(folder, "folderId")) - .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())? - } - }; - - Ok(ExternalCanvasGenerationContext { - project_id, - asset_folder_id, - canvas_name, - }) -} - -fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value { - let (width, height) = match aspect_ratio { - "16:9" => (1024, 576), - "9:16" => (576, 1024), - "3:2" => (1024, 683), - "2:3" => (683, 1024), - _ => (1024, 1024), - }; - serde_json::json!({ - "x": 0, - "y": 0, - "width": width, - "height": height, - "originalWidth": width, - "originalHeight": height, - }) -} - -pub(crate) async fn generate_platform_art_asset_at( - root: &Path, - prompt: &str, - briefs: &[AgentGroupBrief], -) -> Result { - generate_platform_art_asset_with_options_at( - root, - prompt, - briefs, - &PlatformArtAssetGenerationOptions::default(), - ) - .await -} - -pub(super) async fn generate_platform_art_asset_with_options_at( - root: &Path, - prompt: &str, - briefs: &[AgentGroupBrief], - options: &PlatformArtAssetGenerationOptions, -) -> Result { - enforce_project_permission_policy(root, "canvas.asset_generate")?; - init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; - let requested_output = - prepare_platform_art_asset_output_path(root, options.output_path.as_deref())?; - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; - let client = reqwest::Client::new(); - let canvas_context = - prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?; - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); - let generation_kind = if options.asset_kind == "ui-prototype" { - "ui-design" - } else { - "spec" - }; - let response = client - .post(format!( - "{}/api/external/v1/editor/images/generations", - api_base_url - )) - .bearer_auth(&api_key) - .json(&serde_json::json!({ - "prompt": generation_prompt, - "kind": generation_kind, - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetKind": options.asset_kind, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - })) - .send() - .await - .map_err(|error| format!("请求平台图片生成失败:{error}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); - } - let payload = response - .json::() - .await - .map_err(|error| format!("解析平台图片生成响应失败:{error}"))?; - let generated = payload.get("data").unwrap_or(&payload); - let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, generated) - .await? - .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; - let null = serde_json::Value::Null; - let resource = generated.get("resource").unwrap_or(&null); - let asset = generated.get("asset").unwrap_or(&null); - let resource_id = json_string_field(resource, "resourceId"); - let task_id = - json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); - let asset_object_id = json_string_field(generated, "assetObjectId") - .or_else(|| json_string_field(resource, "assetObjectId")) - .or_else(|| json_string_field(asset, "assetObjectId")); - let generated_prompt = json_string_field(generated, "actualPrompt") - .or_else(|| json_string_field(generated, "prompt")) - .or_else(|| json_string_field(resource, "actualPrompt")) - .or_else(|| json_string_field(resource, "prompt")); - let model = - json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); - let provider = json_string_field(generated, "provider") - .or_else(|| json_string_field(resource, "provider")); - let source_hint = json_string_field(generated, "objectKey") - .or_else(|| json_string_field(generated, "imageSrc")); - let extension = infer_file_extension(source_hint.as_deref(), &download.media_type); - let file_stem = resource_id - .as_deref() - .or(task_id.as_deref()) - .unwrap_or("platform-art"); - let (local_path, mut absolute_path) = match requested_output { - Some((local_path, absolute_path)) => { - if !platform_art_asset_output_extension_matches(&local_path, &extension) { - return Err(format!( - "图片生成结果格式为 {extension},与 outputPath 扩展名不一致" - )); - } - (local_path, absolute_path) - } - None => { - let local_path = format!( - "assets/canvas-generated/{}-{}.{}", - unix_millis(), - sanitize_file_name(file_stem), - extension - ); - let absolute_path = resolve_local_project_path(root, &local_path)?; - (local_path, absolute_path) - } - }; - if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?; - } - absolute_path = resolve_local_project_path(root, &local_path)?; - let mut output = fs::OpenOptions::new(); - output.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - output.custom_flags(libc::O_NOFOLLOW); - output.mode(0o600); - } - let mut output = output - .open(&absolute_path) - .map_err(|error| format!("创建平台生成素材失败:{}: {error}", absolute_path.display()))?; - output.write_all(&download.bytes).map_err(|error| { - let _ = fs::remove_file(&absolute_path); - format!("写入平台生成素材失败:{}: {error}", absolute_path.display()) - })?; - drop(output); - let canvas_project_id = json_string_field(resource, "projectId") - .or_else(|| json_string_field(generated, "projectId")) - .or_else(|| Some(canvas_context.project_id.clone())); - let registered = match register_local_asset_entry( - root, - &local_path, - &options.asset_kind, - &download.media_type, - "platform-art", - GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id, - resource_id: resource_id.clone(), - asset_object_id: asset_object_id.clone(), - task_id: task_id.clone(), - prompt: generated_prompt.clone(), - model: model.clone(), - }, - ) { - Ok(registered) => registered, - Err(error) => { - let _ = fs::remove_file(&absolute_path); - return Err(error); - } - }; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": "canvas.asset_generate", - "assetId": registered.id.clone(), - "localPath": registered.local_path.clone(), - "resourceId": resource_id.clone(), - "assetObjectId": asset_object_id.clone(), - "taskId": task_id.clone(), - "model": model.clone(), - "provider": provider.clone(), - "assetFolderId": canvas_context.asset_folder_id, - "canvasName": canvas_context.canvas_name, - }), - )?; - Ok(GeneratedPlatformArtAsset { - asset: registered, - resource_id, - asset_object_id, - task_id, - model, - }) -} - -pub(crate) fn platform_art_asset_art_spec( - options: &PlatformArtAssetGenerationOptions, -) -> serde_json::Value { - if options.asset_kind == "ui-prototype" { - return serde_json::json!({ - "assetType": "ui", - "subject": "完整桌面端游戏 UI 原型,包含 HUD、卡牌控件、战场区和操作控件", - "style": "正视角、清晰分区、可指导 HTML/CSS 实现的高保真 UI/UX mockup", - "palette": "与原创游戏主题一致,文字与控件对比清楚", - "composition": "严格 16:9 单屏界面;顶部资源与波次 HUD,左侧或顶部单位卡槽,中部战场网格,右侧敌人入口,底部或角落放置开始、暂停、重开和操作提示", - "format": format!("{} {}", options.aspect_ratio, options.image_size), - "constraints": "必须明显展示资源数值、单位卡牌、冷却/费用、波次进度、开始或暂停或重开控件和操作反馈;不得只生成无 HUD 的场景插画、战斗概念图、地图或宣传图;不得复刻现有游戏角色、Logo、贴图或受保护视觉语言", - "references": [], - }); - } - serde_json::json!({ - "assetType": "art", - "subject": options.asset_label, - "style": "与当前游戏需求一致的可落地首版视觉", - "composition": format!("{} 游戏素材", options.aspect_ratio), - "format": format!("{} {}", options.aspect_ratio, options.image_size), - "constraints": "必须是可见的真实图片产物,不得用纯文本计划代替", - "references": [], - }) -} - -pub(crate) fn build_platform_art_asset_prompt( - prompt: &str, - briefs: &[AgentGroupBrief], - options: &PlatformArtAssetGenerationOptions, -) -> String { - if options.asset_kind == "ui-prototype" { - return format!( - "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。画面必须是完整 16:9 桌面端单屏界面,明确可见:顶部资源数值与波次/状态 HUD;单位卡牌及费用、冷却状态;中部战场网格;右侧敌人来袭方向;开始、暂停、重开控件;基础操作提示和点击/资源不足等反馈。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画草地、角色和敌人的无 HUD 战斗画面,禁止做海报、地图或纯插画。保持原创主题,不使用现有游戏角色、Logo、贴图或受保护视觉语言。\n\n项目 UI 需求:{}", - truncate_prompt_context(prompt.trim()) - ); - } - let art_asset_brief = briefs - .iter() - .flat_map(|brief| brief.role_briefs.iter()) - .find(|role_brief| { - role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset" - }) - .map(|role_brief| role_brief.markdown.trim()) - .filter(|markdown| !markdown.is_empty()) - .unwrap_or("需要一张可直接用于 Web 小游戏首版原型的核心美术素材。"); - format!( - "为 Web 小游戏首版原型生成一张核心美术素材,适合放入本地 assets 并被 canvas 游戏直接引用。\n用户需求:{}\n美术资产 brief:{}", - truncate_inline(prompt, 240), - truncate_prompt_context(art_asset_brief) - ) -} - -pub(crate) fn render_agent_group_brief_markdown(role_briefs: &[AgentRoleBrief]) -> String { - let mut output = String::new(); - for role_brief in role_briefs { - output.push_str(&format!( - "## {} / {}\n\n- task: {}\n- artifact: {}\n\n{}\n\n", - role_brief.group_definition.label, - role_brief.role_definition.role, - role_brief.role_definition.task_id, - role_brief.relative_path, - role_brief.markdown.trim() - )); - } - output -} - -pub(crate) fn render_agent_role_brief_context(role_brief: &AgentRoleBrief) -> String { - format!( - "## {} / {}\n\n{}\n\n", - role_brief.group_definition.label, - role_brief.role_definition.role, - role_brief.markdown.trim() - ) -} - -pub(crate) fn render_agent_group_briefs_context(briefs: &[AgentGroupBrief]) -> String { - let mut output = String::new(); - for brief in briefs { - output.push_str(&format!( - "## {} / {}\n\n{}\n\n", - brief.definition.label, - brief.definition.role, - brief.markdown.trim() - )); - } - output -} - -pub(crate) fn render_agent_pass_handoff(pass: u8, draft: &LlmGameDraft) -> String { - let mut output = format!("# Agent Handoff\n\n- pass: {pass}\n\n"); - for handoff in &draft.handoffs { - output.push_str(&format!( - "## {} / {}\n\n{}\n\n- outputs: {}\n- next: {}\n\n", - handoff_group_label(&handoff.group), - handoff.role.trim(), - handoff.summary.trim(), - handoff - .outputs - .iter() - .map(|output| output.trim()) - .filter(|output| !output.is_empty()) - .collect::>() - .join(", "), - handoff.next.trim() - )); - } - output.push_str("## 总结\n\n"); - output.push_str(draft.handoff_summary.trim()); - output.push('\n'); - output -} - -pub(crate) fn append_collaboration_steps( - pass: u8, - draft: &LlmGameDraft, - paths: &AgentPassArtifactPaths, - steps: &mut Vec, -) { - let design = - handoff_summary_for_group(draft, "design", "拆出核心循环、胜负条件和第一版关卡目标"); - let balance = handoff_summary_for_group(draft, "balance", "沉淀速度、生命、得分和难度参数"); - let art = handoff_summary_for_group(draft, "art", "整理角色、场景、UI 和动画资产需求"); - let audio = handoff_summary_for_group(draft, "audio", "整理 BGM 和核心交互音效需求"); - let code = - handoff_summary_for_group(draft, "code", "生成可由本地 HTTP server 预览的 canvas 原型"); - let publishing = - handoff_summary_for_group(draft, "publishing", "整理标题、标签、说明和发布前检查"); - steps.push(with_task_context( - agent_trace_step_owned( - pass, - "策划组 / Gameplay", - "completed", - vec![".agent/spec.md".to_string(), paths.draft_json.clone()], - vec![ - paths.design_markdown.clone(), - paths.handoff_markdown.clone(), - ], - &design, - "agent.handoff.design", - ), - "design", - "Gameplay", - Some("design-foundation"), - "handoff", - )); - steps.push(with_task_context( - agent_trace_step_owned( - pass, - "数值组 / Difficulty", - "completed", - vec![paths.design_markdown.clone()], - vec![paths.balance_json.clone(), paths.handoff_markdown.clone()], - &balance, - "agent.handoff.balance", - ), - "balance", - "Difficulty", - Some("balance-seed"), - "handoff", - )); - steps.push(with_task_context( - agent_trace_step_owned( - pass, - "美术组 / Asset", - "completed", - vec![paths.design_markdown.clone()], - vec![ - paths.art_manifest_json.clone(), - paths.handoff_markdown.clone(), - ], - &art, - "agent.handoff.art", - ), - "art", - "Asset", - Some("art-asset-plan"), - "handoff", - )); - steps.push(with_task_context( - agent_trace_step_owned( - pass, - "音乐组 / SFX", - "completed", - vec![paths.design_markdown.clone()], - vec![ - paths.audio_manifest_json.clone(), - paths.handoff_markdown.clone(), - ], - &audio, - "agent.handoff.audio", - ), - "audio", - "SFX", - Some("audio-asset-plan"), - "handoff", - )); - steps.push(with_task_context( - agent_trace_step_owned( - pass, - "程序组 / Code", - "completed", - vec![ - paths.design_markdown.clone(), - paths.balance_json.clone(), - paths.art_manifest_json.clone(), - paths.audio_manifest_json.clone(), - ], - vec![paths.game_html.clone(), paths.handoff_markdown.clone()], - &code, - "agent.handoff.code", - ), - "code", - "Code", - Some("code-prototype"), - "handoff", - )); - steps.push(with_task_context( - agent_trace_step_owned( - pass, - "运营组 / Publish", - "completed", - vec![paths.design_markdown.clone(), paths.game_html.clone()], - vec![paths.publish_readme.clone(), paths.handoff_markdown.clone()], - &publishing, - "agent.handoff.publish", - ), - "publishing", - "Publish", - Some("publish-package"), - "handoff", - )); -} - -pub(crate) fn handoff_summary_for_group( - draft: &LlmGameDraft, - group: &str, - fallback: &str, -) -> String { - draft - .handoffs - .iter() - .find(|handoff| handoff.group.trim() == group) - .map(|handoff| handoff.summary.trim()) - .filter(|summary| !summary.is_empty()) - .unwrap_or(fallback) - .to_string() -} - -pub(crate) fn handoff_group_label(group: &str) -> &'static str { - match group.trim() { - "design" => "策划组", - "balance" => "数值组", - "art" => "美术组", - "audio" => "音乐组", - "code" => "程序组", - "publishing" => "运营组", - _ => "专业组", - } -} - -pub(crate) fn append_agent_loop_log( - root: &Path, - loop_result: &GameCreatorAgentLoopResult, -) -> Result<(), String> { - let agent_log_path = root.join(".agent/logs/agent.log"); - let timestamp = unix_timestamp(); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&agent_log_path) - .and_then(|mut file| { - file.write_all( - format!( - "{timestamp} agent.loop passes={}\nPlanner -> .agent/spec.md\n组内角色 briefs -> .agent/passes/pass-*/groups//*.md\n专业组汇总 -> .agent/passes/pass-*/groups/*.md\nGenerator -> .agent/passes/pass-*/draft.json\n专业组 handoffs -> .agent/passes/pass-*/handoff.md\nEvaluator -> .agent/findings.md\n{}\n{}\n", - loop_result.passes, - loop_result.spec_markdown.trim(), - loop_result.findings_markdown.trim() - ) - .as_bytes(), - ) - }) - .map_err(|error| format!("写入 Agent loop 日志失败:{}: {error}", agent_log_path.display()))?; - append_agent_loop_memory(root, loop_result) -} - -pub(crate) fn append_agent_loop_memory( - root: &Path, - loop_result: &GameCreatorAgentLoopResult, -) -> Result<(), String> { - let trace_path = root.join(".agent/run.latest.json"); - let trace_content = fs::read_to_string(&trace_path).map_err(|error| { - format!( - "读取 Agent run trace 失败:{}: {error}", - trace_path.display() - ) - })?; - let trace = - serde_json::from_str::(&trace_content).map_err(|error| { - format!( - "解析 Agent run trace 失败:{}: {error}", - trace_path.display() - ) - })?; - let timestamp = unix_timestamp(); - let final_artifacts = [ - "game/index.html", - "game/game_design.md", - "game/balance.json", - "assets/manifest.art.json", - "assets/manifest.audio.json", - "exports/README.md", - ] - .into_iter() - .filter(|path| { - trace - .artifacts - .iter() - .any(|artifact| artifact.path == *path) - }) - .collect::>() - .join(", "); - let active_tasks = join_or_none(&trace.task_graph.active_task_ids); - let carried_tasks = join_or_none(&trace.task_graph.carried_task_ids); - let short_entry = format!( - "\n## Agent Run {}\n\n- 时间:{}\n- 标题:{}\n- 状态:{};loop:{}/{};下一步:{}\n- activeTasks:{}\n- carryOverTasks:{}\n- 产物:{}\n", - loop_result.run_id, - timestamp, - loop_result.draft.title.trim(), - trace.status, - trace.passes, - trace.max_passes, - trace.next_step, - active_tasks, - carried_tasks, - if final_artifacts.is_empty() { - "none" - } else { - final_artifacts.as_str() - } - ); - append_markdown_entry( - &root.join("memory/session.md"), - "# 短期记忆\n\n", - &short_entry, - "写入短期 Agent 记忆失败", - )?; - - let long_entry = format!( - "\n## 最近稳定原型\n\n- 时间:{}\n- 标题:{}\n- runId:{}\n- 通过轮次:{}/{}\n- 可试玩入口:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n", - timestamp, - loop_result.draft.title.trim(), - loop_result.run_id, - trace.passes, - trace.max_passes - ); - append_markdown_entry( - &root.join("memory/project.md"), - "# 项目长期记忆\n\n## 当前约束\n\n- Web 小游戏原型\n- 本地 HTTP 预览\n\n", - &long_entry, - "写入长期 Agent 记忆失败", - ) -} - -pub(crate) fn join_or_none(values: &[String]) -> String { - if values.is_empty() { - "none".to_string() - } else { - values.join(", ") - } -} - -pub(crate) fn agent_trace_step( - pass: u8, - agent: &str, - status: &str, - input_paths: &[&str], - output_paths: &[&str], - summary: &str, - tool_id: &str, -) -> GameCreationAgentRunStep { - agent_trace_step_owned( - pass, - agent, - status, - input_paths - .iter() - .map(|path| (*path).to_string()) - .collect::>(), - output_paths - .iter() - .map(|path| (*path).to_string()) - .collect::>(), - summary, - tool_id, - ) -} - -pub(crate) fn agent_trace_step_owned( - pass: u8, - agent: &str, - status: &str, - input_paths: Vec, - output_paths: Vec, - summary: &str, - tool_id: &str, -) -> GameCreationAgentRunStep { - GameCreationAgentRunStep { - pass, - agent: agent.to_string(), - phase: infer_agent_trace_phase(tool_id).to_string(), - task_id: None, - group: None, - role: None, - status: status.to_string(), - input_paths: input_paths.clone(), - output_paths: output_paths.clone(), - summary: summary.to_string(), - tool_calls: vec![GameCreationAgentToolCallTrace { - tool_id: tool_id.to_string(), - status: status.to_string(), - input_paths, - output_paths, - summary: summary.to_string(), - }], - } -} - -pub(crate) fn infer_agent_trace_phase(tool_id: &str) -> &'static str { - if tool_id == "llm.chat.planner" { - "planning" - } else if tool_id.starts_with("agent.task_graph.") { - "orchestration" - } else if tool_id.starts_with("agent.role.brief.") { - "role-brief" - } else if tool_id.starts_with("agent.group.aggregate.") { - "group-aggregate" - } else if tool_id == "llm.chat.generator" { - "generation" - } else if tool_id.starts_with("agent.handoff.") { - "handoff" - } else if tool_id.starts_with("evaluator.") || tool_id == "file.write.findings" { - "evaluation" - } else if tool_id == "file.write.local_artifacts" { - "artifact-write" - } else if tool_id == "game.static_smoke" { - "playtest" - } else if tool_id.starts_with("preview.") { - "preview" - } else { - "tool" - } -} - -pub(crate) fn with_task_context( - mut step: GameCreationAgentRunStep, - group_id: &str, - role: &str, - task_id: Option<&str>, - phase: &str, -) -> GameCreationAgentRunStep { - step.phase = phase.to_string(); - step.group = game_creation_agent_group_from_id(group_id); - step.role = Some(role.to_string()); - step.task_id = task_id.map(str::to_string); - step -} - -pub(crate) fn game_creation_agent_group_from_id( - group_id: &str, -) -> Option { - match group_id { - "design" => Some(GameCreationAppAgentGroup::Design), - "balance" => Some(GameCreationAppAgentGroup::Balance), - "art" => Some(GameCreationAppAgentGroup::Art), - "audio" => Some(GameCreationAppAgentGroup::Audio), - "code" => Some(GameCreationAppAgentGroup::Code), - "publishing" => Some(GameCreationAppAgentGroup::Publishing), - _ => None, - } -} - -pub(super) fn game_creation_agent_group_id(group: &GameCreationAppAgentGroup) -> &'static str { - match group { - GameCreationAppAgentGroup::Design => "design", - GameCreationAppAgentGroup::Balance => "balance", - GameCreationAppAgentGroup::Art => "art", - GameCreationAppAgentGroup::Audio => "audio", - GameCreationAppAgentGroup::Code => "code", - GameCreationAppAgentGroup::Publishing => "publishing", - } -} - -pub(crate) fn append_static_smoke_step( - root: &Path, - prompt: &str, - loop_result: &mut GameCreatorAgentLoopResult, -) -> Result<(), String> { - match run_limited_local_command_at(root, "game.static_smoke") { - Ok(smoke) => { - loop_result.steps.push(with_task_context( - agent_trace_step( - loop_result.passes, - "Playtest", - "completed", - &["game/index.html"], - &[".agent/logs/command.log", ".agent/manifest.json"], - &smoke.output, - "game.static_smoke", - ), - "code", - "Preview", - Some("preview-readiness"), - "playtest", - )); - write_agent_run_trace( - root, - &loop_result.run_id, - prompt, - "passed", - loop_result.passes, - &loop_result.steps, - None, - ) - } - Err(error) => { - loop_result.steps.push(with_task_context( - agent_trace_step( - loop_result.passes, - "Playtest", - "failed", - &["game/index.html"], - &[".agent/logs/command.log"], - &error, - "game.static_smoke", - ), - "code", - "Preview", - Some("preview-readiness"), - "playtest", - )); - write_agent_run_trace( - root, - &loop_result.run_id, - prompt, - "failed", - loop_result.passes, - &loop_result.steps, - Some(&error), - )?; - Err(format!("生成后自检失败:{error}")) - } - } -} - -pub(crate) fn append_static_smoke_manual_trace_step( - root: &Path, - result: &LimitedLocalCommandResult, -) -> Result<(), String> { - append_agent_run_trace_step( - root, - "passed", - "preview-playtest", - with_task_context( - agent_trace_step( - 0, - "Playtest", - "completed", - &["game/index.html"], - &[".agent/logs/command.log", ".agent/manifest.json"], - &result.output, - "game.static_smoke", - ), - "code", - "Preview", - Some("preview-readiness"), - "playtest", - ), - None, - ) -} - -pub(crate) fn append_local_artifact_write_step( - root: &Path, - prompt: &str, - loop_result: &mut GameCreatorAgentLoopResult, -) -> Result<(), String> { - loop_result.steps.push(agent_trace_step_owned( - loop_result.passes, - "ArtifactWriter", - "completed", - vec![ - format!(".agent/passes/pass-{}/draft.json", loop_result.passes), - format!(".agent/passes/pass-{}/handoff.md", loop_result.passes), - ], - vec![ - "memory/session.md".to_string(), - "memory/project.md".to_string(), - PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), - "memory/agents/".to_string(), - "game/game_design.md".to_string(), - "game/balance.json".to_string(), - "assets/manifest.art.json".to_string(), - "assets/manifest.audio.json".to_string(), - "exports/README.md".to_string(), - "game/index.html".to_string(), - ".agent/manifest.json".to_string(), - ], - "把通过 Evaluator 的草案写入本地项目产物", - "file.write.local_artifacts", - )); - write_agent_run_trace( - root, - &loop_result.run_id, - prompt, - "artifacts-written", - loop_result.passes, - &loop_result.steps, - None, - ) -} - -pub(crate) fn append_preview_start_trace_step( - root: &Path, - preview: &LocalPreviewResult, -) -> Result<(), String> { - append_agent_run_trace_step( - root, - "preview-running", - "manual-playtest", - with_task_context( - agent_trace_step( - 0, - "Preview", - "running", - &["game/index.html"], - &[".agent/manifest.json", ".agent/logs/preview.log"], - &format!("本地 HTTP 预览已启动:{}", preview.url), - "preview.start", - ), - "code", - "Playtest", - Some("preview-playtest"), - "preview", - ), - None, - ) -} - -pub(crate) fn append_preview_stop_trace_step(root: &Path) -> Result<(), String> { - append_agent_run_trace_step( - root, - "preview-stopped", - "inspect-artifacts", - with_task_context( - agent_trace_step( - 0, - "Preview", - "stopped", - &[".agent/manifest.json"], - &[".agent/manifest.json", ".agent/logs/preview.log"], - "本地 HTTP 预览已停止", - "preview.stop", - ), - "code", - "Playtest", - Some("preview-playtest"), - "preview", - ), - None, - ) -} - -pub(crate) fn append_preview_log( - root: &Path, - status: &str, - url: Option<&str>, -) -> Result<(), String> { - let log_path = root.join(".agent/logs/preview.log"); - if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建预览日志目录失败:{}: {error}", parent.display()))?; - } - let line = match url { - Some(url) => format!("{} preview.{status} {url}\n", unix_timestamp()), - None => format!("{} preview.{status}\n", unix_timestamp()), - }; - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .and_then(|mut file| file.write_all(line.as_bytes())) - .map_err(|error| format!("写入预览日志失败:{}: {error}", log_path.display())) -} - -pub(crate) fn record_replaced_preview_stop(preview: &LocalPreviewResult) { - let root = Path::new(&preview.root); - let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None); - let _ = append_preview_log(root, "stopped", None); - let _ = append_preview_stop_trace_step(root); -} - -pub(crate) fn append_agent_run_trace_step( - root: &Path, - status: &str, - next_step: &str, - step: GameCreationAgentRunStep, - error: Option<&str>, -) -> Result<(), String> { - let trace_path = root.join(".agent/run.latest.json"); - let content = match fs::read_to_string(&trace_path) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "读取 Agent run trace 失败:{}: {error}", - trace_path.display() - )) - } - }; - let mut trace = - serde_json::from_str::(&content).map_err(|error| { - format!( - "解析 Agent run trace 失败:{}: {error}", - trace_path.display() - ) - })?; - trace.steps.push(step); - trace.status = status.to_string(); - trace.next_step = next_step.to_string(); - trace.error = error.map(str::to_string); - trace.stop_reason = agent_run_stop_reason(status, error).to_string(); - trace.tool_call_count = count_agent_tool_calls(&trace.steps)?; - trace.max_tool_calls = GAME_CREATOR_AGENT_TOOL_CALL_MAX; - trace.artifacts = collect_agent_run_artifacts(root)?; - trace.task_graph = - build_agent_run_task_graph_trace(root, &trace.goal, trace.passes, &trace.steps)?; - trace.pass_plans = collect_agent_pass_plan_traces(root, trace.passes)?; - trace.updated_at = unix_timestamp(); - write_agent_run_trace_payload(root, &trace) -} - -#[derive(Default)] -pub(crate) struct AgentAgendaSnapshot { - active_task_ids: Vec, - carried_task_ids: Vec, - repair_focus: Vec, - repair_routes: Vec, -} - -#[derive(Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct AgentPassTaskGraphSnapshot { - #[serde(default)] - pass: u8, - #[serde(default)] - mode: String, - #[serde(default)] - summary: String, - #[serde(default)] - active_task_ids: Vec, - #[serde(default)] - carried_task_ids: Vec, - #[serde(default)] - dependency_waves: Vec>, - #[serde(default)] - repair_focus: Vec, - #[serde(default)] - repair_routes: Vec, -} - -pub(crate) fn build_agent_run_task_graph_trace( - root: &Path, - goal: &str, - pass: u8, - steps: &[GameCreationAgentRunStep], -) -> Result { - let agenda = read_agent_agenda_snapshot(root, pass)?; - let mut tasks = new_game_creation_app_seed_tasks(); - - for task_id in &agenda.active_task_ids { - set_task_status_if_current(&mut tasks, task_id, GameCreationAppTaskStatus::Running); - } - for task_id in &agenda.carried_task_ids { - set_task_status_if_current(&mut tasks, task_id, GameCreationAppTaskStatus::Completed); - } - - for step in steps { - let Some(task_id) = step.task_id.as_deref() else { - continue; - }; - let Some(status) = task_status_from_agent_step(step, task_id) else { - continue; - }; - set_task_status_if_current(&mut tasks, task_id, status); - } - - if task_has_status( - &tasks, - "preview-readiness", - GameCreationAppTaskStatus::Completed, - ) && !steps.iter().any(|step| { - step.task_id.as_deref() == Some("preview-playtest") - && step.phase == "preview" - && step.status == "running" - }) { - set_task_status_if_current( - &mut tasks, - "preview-playtest", - GameCreationAppTaskStatus::WaitingForConfirmation, - ); - } - - Ok(GameCreationAgentRunTaskGraphTrace { - goal: goal.trim().to_string(), - ready_task_ids: ready_task_ids_for_tasks(&tasks), - active_task_ids: agenda.active_task_ids, - carried_task_ids: agenda.carried_task_ids, - repair_focus: agenda.repair_focus, - repair_routes: agenda.repair_routes, - tasks, - }) -} - -pub(crate) fn read_agent_agenda_snapshot( - root: &Path, - pass: u8, -) -> Result { - if pass == 0 { - return Ok(AgentAgendaSnapshot::default()); - } - let task_graph_relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); - match fs::read_to_string(root.join(&task_graph_relative_path)) { - Ok(content) => { - let snapshot: AgentPassTaskGraphSnapshot = - serde_json::from_str(&content).map_err(|error| { - format!("解析 Agent task graph 失败:{task_graph_relative_path}: {error}") - })?; - return Ok(AgentAgendaSnapshot { - active_task_ids: snapshot.active_task_ids, - carried_task_ids: snapshot.carried_task_ids, - repair_focus: snapshot.repair_focus, - repair_routes: snapshot.repair_routes, - }); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "读取 Agent task graph 失败:{task_graph_relative_path}: {error}" - )) - } - } - let relative_path = format!(".agent/passes/pass-{pass}/agenda.md"); - let agenda = match fs::read_to_string(root.join(&relative_path)) { - Ok(agenda) => agenda, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(AgentAgendaSnapshot::default()) - } - Err(error) => return Err(format!("读取 Agent agenda 失败:{relative_path}: {error}")), - }; - let mut snapshot = AgentAgendaSnapshot::default(); - let mut in_repair_focus = false; - for line in agenda.lines().map(str::trim) { - if let Some(value) = line.strip_prefix("- activeTasks:") { - snapshot.active_task_ids = parse_agent_task_id_list(value); - continue; - } - if let Some(value) = line.strip_prefix("- carriedTasks:") { - snapshot.carried_task_ids = parse_agent_task_id_list(value); - continue; - } - if line == "## Repair Focus" { - in_repair_focus = true; - continue; - } - if line.starts_with("## ") { - in_repair_focus = false; - } - if in_repair_focus { - if let Some(issue) = line.strip_prefix("- ") { - let issue = issue.trim(); - if !issue.is_empty() && !issue.contains("首轮生成") { - snapshot.repair_focus.push(issue.to_string()); - } - } - } - } - Ok(snapshot) -} - -pub(crate) fn collect_agent_pass_plan_traces( - root: &Path, - passes: u8, -) -> Result, String> { - let mut plans = Vec::new(); - for pass in 1..=passes { - let relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); - let content = match fs::read_to_string(root.join(&relative_path)) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "读取 Agent pass plan 失败:{relative_path}: {error}" - )) - } - }; - let snapshot: AgentPassTaskGraphSnapshot = serde_json::from_str(&content) - .map_err(|error| format!("解析 Agent pass plan 失败:{relative_path}: {error}"))?; - plans.push(GameCreationAgentPassPlanTrace { - pass: if snapshot.pass == 0 { - pass - } else { - snapshot.pass - }, - mode: snapshot.mode, - summary: snapshot.summary, - active_task_ids: snapshot.active_task_ids, - carried_task_ids: snapshot.carried_task_ids, - dependency_waves: snapshot.dependency_waves, - repair_focus: snapshot.repair_focus, - repair_routes: snapshot.repair_routes, - }); - } - Ok(plans) -} - -pub(crate) fn parse_agent_task_id_list(value: &str) -> Vec { - value - .split(',') - .map(str::trim) - .filter(|task_id| !task_id.is_empty() && *task_id != "none") - .map(str::to_string) - .collect() -} - -pub(crate) fn task_status_from_agent_step( - step: &GameCreationAgentRunStep, - task_id: &str, -) -> Option { - if step.status == "failed" { - return Some(GameCreationAppTaskStatus::Failed); - } - if step.status == "running" { - return Some(GameCreationAppTaskStatus::Running); - } - if step.status == "stopped" && step.phase == "preview" { - return Some(GameCreationAppTaskStatus::WaitingForConfirmation); - } - if step.status == "carried-over" { - return Some(GameCreationAppTaskStatus::Completed); - } - if step.status != "completed" && step.status != "passed" { - return None; - } - - match step.phase.as_str() { - "planning" | "handoff" | "playtest" | "evaluation" => { - Some(GameCreationAppTaskStatus::Completed) - } - "preview" => Some(GameCreationAppTaskStatus::Running), - "role-brief" if role_brief_completes_task(task_id) => { - Some(GameCreationAppTaskStatus::Completed) - } - "role-brief" => Some(GameCreationAppTaskStatus::Running), - _ => None, - } -} - -pub(crate) fn role_brief_completes_task(task_id: &str) -> bool { - matches!( - task_id, - "balance-director" - | "art-director" - | "art-polish" - | "audio-director" - | "code-director" - | "publish-strategy" - ) -} - -pub(crate) fn set_task_status_if_current( - tasks: &mut [GameCreationAppTaskState], - task_id: &str, - status: GameCreationAppTaskStatus, -) { - if let Some(task) = tasks.iter_mut().find(|task| task.id == task_id) { - if should_replace_task_status(&task.status, &status) { - task.status = status; - } - } -} - -pub(crate) fn should_replace_task_status( - current: &GameCreationAppTaskStatus, - next: &GameCreationAppTaskStatus, -) -> bool { - use GameCreationAppTaskStatus as Status; - status_rank(next) >= status_rank(current) - || matches!( - (current, next), - (Status::Running, Status::Completed) - | (Status::WaitingForConfirmation, Status::Running) - | (Status::Pending, _) - ) -} - -pub(crate) fn status_rank(status: &GameCreationAppTaskStatus) -> u8 { - match status { - GameCreationAppTaskStatus::Pending => 0, - GameCreationAppTaskStatus::Running => 1, - GameCreationAppTaskStatus::WaitingForConfirmation => 2, - GameCreationAppTaskStatus::Completed => 3, - GameCreationAppTaskStatus::Failed => 4, - } -} - -pub(crate) fn task_has_status( - tasks: &[GameCreationAppTaskState], - task_id: &str, - status: GameCreationAppTaskStatus, -) -> bool { - tasks - .iter() - .find(|task| task.id == task_id) - .is_some_and(|task| task.status == status) -} - -pub(crate) fn ready_task_ids_for_tasks(tasks: &[GameCreationAppTaskState]) -> Vec { - tasks - .iter() - .filter(|task| { - task.status == GameCreationAppTaskStatus::Pending - && task.dependencies.iter().all(|dependency| { - task_has_status(tasks, dependency, GameCreationAppTaskStatus::Completed) - }) - }) - .map(|task| task.id.clone()) - .collect() -} - -pub(crate) fn write_agent_run_trace( - root: &Path, - run_id: &str, - prompt: &str, - status: &str, - passes: u8, - steps: &[GameCreationAgentRunStep], - error: Option<&str>, -) -> Result<(), String> { - let tool_call_count = count_agent_tool_calls(steps)?; - let trace = GameCreationAgentRunTrace { - schema_version: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION.to_string(), - run_id: run_id.to_string(), - command_id: "game.generate_draft".to_string(), - status: status.to_string(), - lifecycle_status: Some(agent_run_lifecycle_status(status).to_string()), - passes, - max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES, - tool_call_count, - max_tool_calls: GAME_CREATOR_AGENT_TOOL_CALL_MAX, - stop_reason: agent_run_stop_reason(status, error).to_string(), - goal: prompt.trim().to_string(), - coordination: "filesystem".to_string(), - steps: steps.to_vec(), - artifacts: collect_agent_run_artifacts(root)?, - task_graph: build_agent_run_task_graph_trace(root, prompt, passes, steps)?, - pass_plans: collect_agent_pass_plan_traces(root, passes)?, - next_step: match status { - "passed" => "preview-playtest", - "artifacts-written" => "game.static_smoke", - "failed" => "inspect-error", - _ => "generator-revision", - } - .to_string(), - error: error.map(str::to_string), - updated_at: unix_timestamp(), - }; - write_agent_run_trace_payload(root, &trace) -} - -pub(crate) fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'static str { - match status { - "running" => "loop-running", - "needs-revision" => "evaluator-needs-revision", - "passed" => "evaluator-passed", - "artifacts-written" => "artifacts-written", - "preview-running" => "preview-running", - "preview-stopped" => "preview-stopped", - "failed" if error.is_some_and(|message| message.contains("已重试")) => { - "max-passes-exhausted" - } - "failed" => "failed", - _ => "unknown", - } -} - -pub(crate) fn agent_run_lifecycle_status(status: &str) -> &'static str { - match status { - "running" | "needs-revision" | "artifacts-written" | "preview-running" => "running", - "waiting" | "preview-stopped" => "waiting", - "pending" => "pending", - "killed" => "killed", - "failed" => "failed", - "passed" => "done", - _ => "scheduled", - } -} - -pub(crate) fn append_agent_run_activity( - root: &Path, - run_id: &str, - event: &str, - message: &str, -) -> Result<(), String> { - append_agent_run_jsonl( - root, - ".agent/activity.jsonl", - &serde_json::json!({ - "timestamp": unix_timestamp(), - "runId": run_id, - "event": event, - "message": message, - }), - ) -} - -pub(crate) fn append_agent_run_output( - root: &Path, - run_id: &str, - event: &str, - message: &str, -) -> Result<(), String> { - append_agent_run_jsonl( - root, - ".agent/output.jsonl", - &serde_json::json!({ - "timestamp": unix_timestamp(), - "runId": run_id, - "event": event, - "content": message, - }), - ) -} - -pub(crate) fn append_agent_run_jsonl( - root: &Path, - relative_path: &str, - value: &serde_json::Value, -) -> Result<(), String> { - let path = root.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?; - } - let line = - serde_json::to_string(value).map_err(|error| format!("序列化 Agent 事件失败:{error}"))?; - append_jsonl_line(&path, &line, "Agent 事件") -} - -pub(crate) fn write_agent_run_context_bundle( - root: &Path, - trace: &GameCreationAgentRunTrace, -) -> Result<(), String> { - let bundle_path = root.join(".agent/context.bundle.json"); - if let Some(parent) = bundle_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent context bundle 目录失败:{}: {error}", - parent.display() - ) - })?; - } - let manifest = read_manifest_for_project(root).ok(); - let payload = serde_json::json!({ - "schemaVersion": "game-creator-context-bundle.v1", - "runId": trace.run_id, - "commandId": trace.command_id, - "goal": trace.goal, - "lifecycleStatus": trace.lifecycle_status.as_deref().unwrap_or_else(|| agent_run_lifecycle_status(&trace.status)), - "status": trace.status, - "nextStep": trace.next_step, - "memory": { - "short": "memory/session.md", - "long": "memory/project.md", - "blackboard": PROJECT_BLACKBOARD_MEMORY_PATH, - "agents": "memory/agents/" - }, - "manifest": manifest, - "trace": ".agent/run.latest.json", - "activity": ".agent/activity.jsonl", - "output": ".agent/output.jsonl", - "updatedAt": unix_timestamp() - }); - let content = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("生成 Agent context bundle 失败:{error}"))?; - fs::write(&bundle_path, content).map_err(|error| { - format!( - "写入 Agent context bundle 失败:{}: {error}", - bundle_path.display() - ) - }) -} - -pub(crate) fn update_agent_run_lifecycle( - root: &Path, - action: &str, - detail: Option<&str>, -) -> Result { - let mut trace = read_latest_agent_run_trace(root)?; - let (status, lifecycle_status, next_step, event, message) = match action { - "status" => { - let lifecycle = trace - .lifecycle_status - .clone() - .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()); - ( - trace.status.clone(), - lifecycle.clone(), - trace.next_step.clone(), - "agent.run_status", - format!( - "run {} 当前状态:{} / {}", - trace.run_id, trace.status, lifecycle - ), - ) - } - "kill" => ( - "killed".to_string(), - "killed".to_string(), - "resume-or-retry".to_string(), - "agent.kill", - format!("run {} 已标记为 killed", trace.run_id), - ), - "retry" => ( - "pending".to_string(), - "pending".to_string(), - "rerun-now".to_string(), - "agent.retry", - format!("run {} 已请求重试", trace.run_id), - ), - "resume" => ( - "pending".to_string(), - "pending".to_string(), - "rerun-now".to_string(), - "agent.resume", - format!( - "run {} 已恢复:{}", - trace.run_id, - detail.unwrap_or("继续运行最近目标") - ), - ), - _ => return Err("未知 Agent run 控制动作".to_string()), - }; - - if action != "status" { - trace.status = status; - trace.lifecycle_status = Some(lifecycle_status); - trace.next_step = next_step; - trace.stop_reason = match action { - "kill" => "killed", - "retry" => "retry-requested", - "resume" => "human-resume", - _ => trace.stop_reason.as_str(), - } - .to_string(); - trace.error = if action == "kill" { - Some("用户请求停止当前 run".to_string()) - } else { - None - }; - trace.updated_at = unix_timestamp(); - write_agent_run_trace_payload(root, &trace)?; - } - - append_agent_run_activity(root, &trace.run_id, event, &message)?; - append_agent_run_output(root, &trace.run_id, event, &message)?; - write_agent_run_context_bundle(root, &trace)?; - - agent_run_control_result_from_trace(root, trace, message) -} - -pub(crate) async fn control_agent_run_at( - root: &Path, - action: &str, - detail: Option<&str>, - progress: Option<&AgentProgressEmitter<'_>>, -) -> Result { - let previous_trace = read_latest_agent_run_trace(root)?; - let control_result = update_agent_run_lifecycle(root, action, detail)?; - if !matches!(action, "retry" | "resume") { - return Ok(control_result); - } - - let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail); - let generated = generate_local_game_draft_at(root, &prompt, progress).await?; - let trace = read_latest_agent_run_trace(root)?; - let message = format!( - "{},已重新运行为 {}:{}", - control_result.message, trace.run_id, generated.game_index_path - ); - let event = if action == "retry" { - "agent.retry.run" - } else { - "agent.resume.run" - }; - append_agent_run_activity(root, &trace.run_id, event, &message)?; - append_agent_run_output(root, &trace.run_id, event, &message)?; - write_agent_run_context_bundle(root, &trace)?; - agent_run_control_result_from_trace(root, trace, message) -} - -pub(crate) fn resumed_agent_run_prompt(goal: &str, action: &str, detail: Option<&str>) -> String { - let goal = goal.trim(); - let detail = detail.map(str::trim).filter(|value| !value.is_empty()); - match (action, detail) { - ("resume", Some(detail)) => format!("{goal}\n\n继续说明:{detail}"), - _ => goal.to_string(), - } -} - -pub(crate) fn read_latest_agent_run_trace( - root: &Path, -) -> Result { - let trace_path = root.join(".agent/run.latest.json"); - let content = fs::read_to_string(&trace_path).map_err(|error| { - format!( - "读取 Agent run trace 失败:{}: {error}", - trace_path.display() - ) - })?; - serde_json::from_str::(&content).map_err(|error| { - format!( - "解析 Agent run trace 失败:{}: {error}", - trace_path.display() - ) - }) -} - -pub(crate) fn agent_run_control_result_from_trace( - root: &Path, - trace: GameCreationAgentRunTrace, - message: String, -) -> Result { - Ok(AgentRunControlResult { - run_id: trace.run_id, - lifecycle_status: trace - .lifecycle_status - .clone() - .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()), - status: trace.status, - next_step: trace.next_step, - message, - activity_path: root - .join(".agent/activity.jsonl") - .to_string_lossy() - .to_string(), - output_path: root - .join(".agent/output.jsonl") - .to_string_lossy() - .to_string(), - context_bundle_path: root - .join(".agent/context.bundle.json") - .to_string_lossy() - .to_string(), - }) -} - -pub(crate) fn count_agent_tool_calls(steps: &[GameCreationAgentRunStep]) -> Result { - let count = steps.iter().try_fold(0u16, |current, step| { - let step_count = u16::try_from(step.tool_calls.len()) - .map_err(|_| "Agent 工具调用数超过上限".to_string())?; - current - .checked_add(step_count) - .ok_or_else(|| "Agent 工具调用数超过上限".to_string()) - })?; - if count > GAME_CREATOR_AGENT_TOOL_CALL_MAX { - return Err(format!( - "Agent 工具调用预算超限:{count}/{GAME_CREATOR_AGENT_TOOL_CALL_MAX}" - )); - } - Ok(count) -} - -pub(crate) fn write_agent_run_trace_payload( - root: &Path, - trace: &GameCreationAgentRunTrace, -) -> Result<(), String> { - if trace.run_id.contains('/') || trace.run_id.contains('\\') || trace.run_id.contains("..") { - return Err("Agent run_id 非法".to_string()); - } - let payload = serde_json::to_string_pretty(&trace) - .map_err(|error| format!("生成 Agent run trace 失败:{error}"))?; - let latest_path = root.join(".agent/run.latest.json"); - fs::write(&latest_path, &payload).map_err(|error| { - format!( - "写入 Agent run trace 失败:{}: {error}", - latest_path.display() - ) - })?; - let run_dir = root.join(".agent/runs"); - fs::create_dir_all(&run_dir).map_err(|error| { - format!( - "创建 Agent run history 目录失败:{}: {error}", - run_dir.display() - ) - })?; - let run_path = run_dir.join(format!("{}.json", trace.run_id)); - fs::write(&run_path, payload).map_err(|error| { - format!( - "写入 Agent run history 失败:{}: {error}", - run_path.display() - ) - })?; - prune_agent_run_history(&run_dir) -} - -pub(crate) fn prune_agent_run_history(run_dir: &Path) -> Result<(), String> { - let entries = match fs::read_dir(run_dir) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(format!( - "读取 Agent run history 失败:{}: {error}", - run_dir.display() - )); - } - }; - let mut run_files = Vec::new(); - for entry in entries { - let entry = entry.map_err(|error| { - format!( - "读取 Agent run history 失败:{}: {error}", - run_dir.display() - ) - })?; - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("json") { - continue; - } - let updated_at = fs::read_to_string(&path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .map(|trace| trace.updated_at) - .unwrap_or(0); - run_files.push((updated_at, path)); - } - if run_files.len() <= GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT { - return Ok(()); - } - run_files.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); - for (_, path) in run_files - .into_iter() - .skip(GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT) - { - fs::remove_file(&path).map_err(|error| { - format!("删除旧 Agent run history 失败:{}: {error}", path.display()) - })?; - } - Ok(()) -} - -pub(crate) fn collect_agent_run_artifacts( - root: &Path, -) -> Result, String> { - let mut relative_paths = GAME_CREATOR_AGENT_ARTIFACT_PATHS - .iter() - .map(|path| (*path).to_string()) - .collect::>(); - let passes_dir = root.join(".agent/passes"); - if passes_dir.exists() { - let mut pass_dirs = fs::read_dir(&passes_dir) - .map_err(|error| { - format!( - "读取 Agent pass 目录失败:{}: {error}", - passes_dir.display() - ) - })? - .collect::, _>>() - .map_err(|error| { - format!( - "读取 Agent pass 目录失败:{}: {error}", - passes_dir.display() - ) - })?; - pass_dirs.sort_by_key(|entry| entry.path()); - for pass_dir in pass_dirs { - let pass_path = pass_dir.path(); - let metadata = fs::symlink_metadata(&pass_path).map_err(|error| { - format!( - "读取 Agent pass 元数据失败:{}: {error}", - pass_path.display() - ) - })?; - if metadata.file_type().is_symlink() { - return Err("Agent pass 目录不能是符号链接".to_string()); - } - if !metadata.is_dir() { - continue; - } - let mut dirs = vec![pass_path]; - while let Some(dir) = dirs.pop() { - let mut entries = fs::read_dir(&dir) - .map_err(|error| { - format!("读取 Agent pass 文件失败:{}: {error}", dir.display()) - })? - .collect::, _>>() - .map_err(|error| { - format!("读取 Agent pass 文件失败:{}: {error}", dir.display()) - })?; - entries.sort_by_key(|entry| entry.path()); - for entry in entries { - let entry_path = entry.path(); - let metadata = fs::symlink_metadata(&entry_path).map_err(|error| { - format!( - "读取 Agent pass 文件元数据失败:{}: {error}", - entry_path.display() - ) - })?; - if metadata.file_type().is_symlink() { - return Err("Agent pass artifact 不能是符号链接".to_string()); - } - if metadata.is_dir() { - dirs.push(entry_path); - } else if metadata.is_file() { - relative_paths.push(relative_project_path(root, &entry_path)?); - } - } - } - } - } - let agent_memory_dir = root.join("memory/agents"); - if agent_memory_dir.exists() { - let mut dirs = vec![agent_memory_dir]; - while let Some(dir) = dirs.pop() { - let mut entries = fs::read_dir(&dir) - .map_err(|error| { - format!("读取 Agent 私有记忆目录失败:{}: {error}", dir.display()) - })? - .collect::, _>>() - .map_err(|error| { - format!("读取 Agent 私有记忆目录失败:{}: {error}", dir.display()) - })?; - entries.sort_by_key(|entry| entry.path()); - for entry in entries { - let entry_path = entry.path(); - let metadata = fs::symlink_metadata(&entry_path).map_err(|error| { - format!( - "读取 Agent 私有记忆元数据失败:{}: {error}", - entry_path.display() - ) - })?; - if metadata.file_type().is_symlink() { - return Err("Agent 私有记忆不能是符号链接".to_string()); - } - if metadata.is_dir() { - dirs.push(entry_path); - } else if metadata.is_file() { - relative_paths.push(relative_project_path(root, &entry_path)?); - } - } - } - } - - relative_paths.sort(); - relative_paths.dedup(); - let mut artifacts = Vec::new(); - for relative_path in relative_paths { - let path = root.join(&relative_path); - let metadata = match fs::symlink_metadata(&path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => { - return Err(format!( - "读取 Agent artifact 元数据失败:{}: {error}", - path.display() - )) - } - }; - if metadata.file_type().is_symlink() { - return Err("Agent artifact 不能是符号链接".to_string()); - } - if !metadata.is_file() { - continue; - } - let bytes = fs::read(&path) - .map_err(|error| format!("读取 Agent artifact 失败:{}: {error}", path.display()))?; - artifacts.push(GameCreationAgentArtifactTrace { - path: relative_path, - size_bytes: metadata.len(), - checksum: format!("fnv1a64:{:016x}", fnv1a64(&bytes)), - }); - } - Ok(artifacts) -} - -pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 { - let mut hash = 0xcbf29ce484222325_u64; - for byte in bytes { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x100000001b3); - } - hash -} - -pub(crate) fn parse_llm_game_draft_response(content: &str) -> Result { - let content = strip_llm_thinking_blocks(content); - let payload = extract_json_payload(content.as_str()) - .ok_or_else(|| "LLM 返回不是 JSON 对象".to_string())?; - serde_json::from_str::(payload) - .map_err(|error| format!("解析 LLM 游戏草案失败:{error}")) -} - -pub(crate) fn strip_llm_thinking_blocks(content: &str) -> String { - let mut output = String::new(); - let mut rest = content; - loop { - let Some(start) = rest.to_ascii_lowercase().find("") else { - output.push_str(rest); - break; - }; - output.push_str(&rest[..start]); - let after_start = &rest[start + "".len()..]; - let Some(end) = after_start.to_ascii_lowercase().find("") else { - break; - }; - rest = &after_start[end + "".len()..]; - } - output.trim().to_string() -} - -pub(crate) fn extract_json_payload(content: &str) -> Option<&str> { - let trimmed = content.trim(); - let without_fence = trimmed - .strip_prefix("```json") - .or_else(|| trimmed.strip_prefix("```")) - .and_then(|value| value.strip_suffix("```")) - .map(str::trim) - .unwrap_or(trimmed); - let start = without_fence.find('{')?; - let mut depth = 0usize; - let mut inside_string = false; - let mut escaped = false; - for (offset, character) in without_fence[start..].char_indices() { - if inside_string { - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == '"' { - inside_string = false; - } - continue; - } - match character { - '"' => inside_string = true, - '{' => depth += 1, - '}' => { - depth = depth.checked_sub(1)?; - if depth == 0 { - let end = start + offset + character.len_utf8(); - return Some(&without_fence[start..end]); - } - } - _ => {} - } - } - None -} - -pub(crate) fn validate_llm_game_draft(prompt: &str, draft: &LlmGameDraft) -> Result<(), String> { - if draft.title.trim().is_empty() { - return Err("LLM 草案缺少标题".to_string()); - } - if draft.design_markdown.trim().is_empty() { - return Err("LLM 草案缺少设计说明".to_string()); - } - if !draft.balance.is_object() { - return Err("LLM 草案 balance 必须是 JSON object".to_string()); - } - if !draft.art_manifest.is_object() { - return Err("LLM 草案 artManifest 必须是 JSON object".to_string()); - } - if !draft.audio_manifest.is_object() { - return Err("LLM 草案 audioManifest 必须是 JSON object".to_string()); - } - if draft.publish_readme.trim().is_empty() { - return Err("LLM 草案缺少发布说明".to_string()); - } - if draft.handoff_summary.trim().is_empty() { - return Err("LLM 草案缺少多智能体交接摘要".to_string()); - } - validate_llm_agent_handoffs(draft)?; - - let html = draft.game_html.trim(); - let lower_html = html.to_ascii_lowercase(); - if !lower_html.contains("')) && html.contains(prompt) { - return Err("LLM 草案 gameHtml 包含未转义的用户输入".to_string()); - } - validate_playable_game_html(html, "LLM 草案 gameHtml")?; - validate_non_placeholder_game_html(html, "LLM 草案 gameHtml")?; - - Ok(()) -} - -pub(crate) fn validate_playable_game_html(html: &str, label: &str) -> Result<(), String> { - let lower_html = html.to_ascii_lowercase(); - if !contains_any( - &lower_html, - &[ - "目标", - "任务", - "goal", - "objective", - "点亮", - "收集", - "抵达", - "获胜", - "通关", - "连击", - "combo", - "survive", - ], - ) { - return Err(format!("{label} 必须展示明确目标")); - } - if !contains_any( - &lower_html, - &[ - "胜利", - "获胜", - "失败", - "game over", - "win", - "lose", - "victory", - "defeat", - ], - ) { - return Err(format!("{label} 必须包含失败或胜利状态")); - } - if !contains_any( - &lower_html, - &["重开", "重新开始", "restart", "reset", "again", "再来"], - ) { - return Err(format!("{label} 必须包含重开路径")); - } - Ok(()) -} - -pub(crate) fn validate_safe_game_html_runtime(html: &str, label: &str) -> Result<(), String> { - let lower_html = html.to_ascii_lowercase(); - if lower_html.contains("".to_string()); - } - let opening_end = lower_html[opening..] - .find('>') - .map(|offset| opening + offset + 1) - .ok_or_else(|| "游戏入口的 结束标签未闭合".to_string())?; - } - if next_script_tag(lower_html, cursor, true).is_some() { - return Err("游戏入口包含没有对应开始标签的 ".to_string()); - } - Ok(()) -} - -pub(crate) fn validate_canvas_rendering_html(html: &str, label: &str) -> Result<(), String> { - let lower_html = html.to_ascii_lowercase(); - if !lower_html.contains("getcontext(") && !lower_html.contains(".getcontext") { - return Err(format!("{label} 必须获取 canvas 渲染上下文")); - } - if !contains_any( - &lower_html, - &[ - "fillrect(", - "strokerect(", - "drawimage(", - "filltext(", - ".arc(", - ".fill(", - ".stroke(", - "putimagedata(", - "drawarrays(", - "drawelements(", - ], - ) { - return Err(format!("{label} 必须在 canvas 上绘制画面")); - } - Ok(()) -} - -pub(crate) fn validate_non_placeholder_game_html(html: &str, label: &str) -> Result<(), String> { - let lower_html = html.to_ascii_lowercase(); - for forbidden in [ - "星核传送门", - "点击按钮加分", - "点击按钮得分", - "todo:", - "待实现", - "这里省略", - ] { - if lower_html.contains(forbidden) { - return Err(format!("{label} 不能是固定模板或未完成实现:{forbidden}")); - } - } - let compact = lower_html - .chars() - .filter(|character| !character.is_whitespace()) - .collect::(); - if compact.contains("addeventlistener(") - && (compact.contains("=>{})") || compact.contains("function(){}")) - { - return Err(format!("{label} 的输入监听不能是空实现")); - } - Ok(()) -} - -pub(crate) fn contains_any(haystack: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| haystack.contains(needle)) -} - -pub(crate) fn validate_llm_agent_handoffs(draft: &LlmGameDraft) -> Result<(), String> { - const REQUIRED_GROUPS: [&str; 6] = ["design", "balance", "art", "audio", "code", "publishing"]; - for required_group in REQUIRED_GROUPS { - let handoff = draft - .handoffs - .iter() - .find(|handoff| handoff.group.trim() == required_group) - .ok_or_else(|| format!("LLM 草案 handoffs 缺少 {required_group} 专业组"))?; - if handoff.role.trim().is_empty() - || handoff.summary.trim().is_empty() - || handoff.next.trim().is_empty() - || handoff - .outputs - .iter() - .all(|output| output.trim().is_empty()) - { - return Err(format!("LLM 草案 handoffs.{required_group} 交接内容不完整")); - } - } - Ok(()) -} - -pub(crate) fn game_creator_system_prompt() -> &'static str { - r#"你是 Genarrative 的 AI 游戏创作 Generator。你必须读取 Planner 规格、六个专业组 agent brief(这些 brief 已由组内 Director / Gameplay / Asset / Code / Preview / Playtest / Polish / Publish 等角色分别产出并汇总)和 Evaluator findings,把它们整合为一个本地可运行 Web 游戏原型。只返回 JSON,不返回 Markdown 解释。 - -JSON schema: -{ - "title": "游戏标题", - "designMarkdown": "策划组输出,包含核心循环、输入、胜负条件、关卡目标", - "balance": { "playerSpeed": 180, "playerLives": 3, "difficultyRamp": "..." }, - "artManifest": { "source": "llm", "items": [ { "kind": "character|scene|ui|animation", "title": "...", "status": "needs-canvas|generated" } ] }, - "audioManifest": { "source": "llm", "items": [ { "kind": "background-music|sound-effect", "title": "...", "status": "needs-canvas|generated" } ] }, - "publishReadme": "运营组输出,包含标题、简介、标签、封面需求和下一步验收", - "handoffs": [ - { "group": "design", "role": "Gameplay", "summary": "策划交接摘要", "outputs": ["game/game_design.md"], "next": "交给数值、美术、音乐、程序组" }, - { "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" }, - { "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" }, - { "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" }, - { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" }, - { "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" } - ], - "handoffSummary": "六组 agent 的交接摘要,每组一行", - "gameHtml": "完整自包含 HTML,可直接保存为 game/index.html" -} - -gameHtml 规则: -- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 -- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 -- JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。 -- 玩法、文本、数值和视觉主题必须明显响应用户需求、Planner 规格和组内角色 brief,不要输出固定星核传送门模板。 -"# -} - -pub(crate) fn game_creator_planner_system_prompt() -> &'static str { - r#"你是 Genarrative 的 AI 游戏创作 Planner。输出一份给 Generator 使用的 Markdown 规格,不要生成代码。规格必须包含:核心循环、输入方式、胜负条件、首版关卡、6 个专业组分工、组内角色任务矩阵、Evaluator 验收标准。不要写客套说明。"# -} - -pub(crate) fn game_creator_planner_user_prompt( - prompt: &str, - short_memory: &str, - long_memory: &str, - project_blackboard: &str, -) -> String { - format!( - "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\n请输出 Planner 规格 Markdown。", - prompt.trim(), - truncate_prompt_context(short_memory), - truncate_prompt_context(long_memory), - truncate_prompt_context(project_blackboard) - ) -} - -pub(crate) fn game_creator_generator_user_prompt( - prompt: &str, - short_memory: &str, - long_memory: &str, - project_blackboard: &str, - spec_markdown: &str, - findings_markdown: &str, - group_briefs_markdown: &str, - agenda_markdown: &str, -) -> String { - format!( - "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\nPlanner 规格文件 .agent/spec.md:\n{}\n\n本轮 Orchestrator agenda:\n{}\n\n六个专业组 agent brief(每组由组内角色汇总而成):\n{}\n\nEvaluator 反馈文件 .agent/findings.md:\n{}\n\n请直接返回满足 schema 的 JSON。如果 findings 有问题,必须优先修复 agenda 中 activeTasks 对应的任务。", - prompt.trim(), - truncate_prompt_context(short_memory), - truncate_prompt_context(long_memory), - truncate_prompt_context(project_blackboard), - truncate_prompt_context(spec_markdown), - truncate_prompt_context(agenda_markdown), - truncate_prompt_context(group_briefs_markdown), - truncate_prompt_context(findings_markdown) - ) -} - -pub(crate) fn append_prompt_context(base: &str, extra: &str) -> String { - match (base.trim().is_empty(), extra.trim().is_empty()) { - (_, true) => base.to_string(), - (true, false) => extra.trim().to_string(), - (false, false) => format!("{}\n\n{}", base.trim_end(), extra.trim()), - } -} - -pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result { - let manifest = read_manifest_for_project(root)?; - if manifest.assets.is_empty() { - return Ok(String::new()); - } - - let mut output = "# 本地项目资产\n\n".to_string(); - for asset in manifest.assets.iter().take(24) { - output.push_str("- "); - output.push_str(&asset.id); - output.push_str(": "); - output.push_str(&asset.kind); - output.push_str(" / "); - output.push_str(&asset.media_type); - output.push_str(" / "); - output.push_str(&asset.local_path); - output.push_str(" / source="); - output.push_str(asset_source_kind_label(&asset.source.kind)); - if let Some(canvas_project_id) = asset.source.canvas_project_id.as_deref() { - output.push_str(" / canvasProjectId="); - output.push_str(canvas_project_id); - } - if let Some(resource_id) = asset.source.resource_id.as_deref() { - output.push_str(" / resourceId="); - output.push_str(resource_id); - } - if let Some(asset_object_id) = asset.source.asset_object_id.as_deref() { - output.push_str(" / assetObjectId="); - output.push_str(asset_object_id); - } - if let Some(task_id) = asset.source.task_id.as_deref() { - output.push_str(" / taskId="); - output.push_str(task_id); - } - if let Some(model) = asset.source.model.as_deref() { - output.push_str(" / model="); - output.push_str(model); - } - output.push('\n'); - } - if manifest.assets.len() > 24 { - output.push_str(&format!( - "- ... 还有 {} 个资产\n", - manifest.assets.len() - 24 - )); - } - Ok(output) -} - -pub(crate) fn render_local_conversation_prompt_context( - root: &Path, - agent_id: Option<&str>, -) -> Result { - render_local_conversation_prompt_context_for_session(root, agent_id, None) -} - -pub(crate) fn render_local_conversation_prompt_context_for_session( - root: &Path, - agent_id: Option<&str>, - session_id: Option<&str>, -) -> Result { - #[derive(Debug)] - struct ConversationPromptEntry { - updated_at: u64, - agent_label: String, - role: String, - content: String, - } - - fn push_conversation_entries( - entries: &mut Vec, - conversation: LocalConversationResult, - agent_label: &str, - ) { - for message in conversation.messages { - let content = sanitize_prompt_context(&message.content) - .split_whitespace() - .collect::>() - .join(" "); - if content.is_empty() { - continue; - } - entries.push(ConversationPromptEntry { - updated_at: message.updated_at, - agent_label: agent_label.to_string(), - role: message.role, - content, - }); - } - } - - validate_project_root(root)?; - let mut entries = Vec::new(); - push_conversation_entries( - &mut entries, - read_local_conversation_for_session_at(root, None, None)?, - "project", - ); - - if let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) { - if agent_id == "*" { - if session_id - .map(str::trim) - .is_some_and(|value| !value.is_empty()) - { - return Err("读取全部 Agent 对话时不能指定单个 sessionId".to_string()); - } - let agents_dir = root.join(".agent/conversations/agents"); - match fs::read_dir(&agents_dir) { - Ok(read_dir) => { - let mut agent_ids = std::collections::BTreeSet::new(); - for entry in read_dir { - let entry = entry.map_err(|error| { - format!("读取 Agent 对话目录失败:{}: {error}", agents_dir.display()) - })?; - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) == Some("jsonl") { - if let Some(agent_id) = - path.file_stem().and_then(|value| value.to_str()) - { - agent_ids.insert(agent_id.to_string()); - } - } else if path.is_dir() { - if let Some(agent_id) = - path.file_name().and_then(|value| value.to_str()) - { - agent_ids.insert(agent_id.to_string()); - } - } - } - for agent_id in agent_ids { - push_conversation_entries( - &mut entries, - read_local_conversation_for_session_at(root, Some(&agent_id), None)?, - &agent_id, - ); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "读取 Agent 对话目录失败:{}: {error}", - agents_dir.display() - )); - } - } - } else { - push_conversation_entries( - &mut entries, - read_local_conversation_for_session_at(root, Some(agent_id), session_id)?, - agent_id, - ); - } - } else if session_id - .map(str::trim) - .is_some_and(|value| !value.is_empty()) - { - return Err("项目主对话不接受 sessionId".to_string()); - } - - if entries.is_empty() { - return Ok(String::new()); - } - entries.sort_by(|left, right| { - right - .updated_at - .cmp(&left.updated_at) - .then_with(|| left.agent_label.cmp(&right.agent_label)) - .then_with(|| left.role.cmp(&right.role)) - }); - entries.truncate(GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES); - entries.sort_by(|left, right| { - left.updated_at - .cmp(&right.updated_at) - .then_with(|| left.agent_label.cmp(&right.agent_label)) - .then_with(|| left.role.cmp(&right.role)) - }); - - let mut output = "# 最近对话上下文\n\n".to_string(); - for entry in entries { - output.push_str(&format!( - "- [{} / {}] {}\n", - entry.agent_label, entry.role, entry.content - )); - } - Ok(output) -} - -pub(crate) fn asset_source_kind_label(kind: &GameCreationAppAssetSourceKind) -> &'static str { - match kind { - GameCreationAppAssetSourceKind::Uploaded => "uploaded", - GameCreationAppAssetSourceKind::Generated => "generated", - GameCreationAppAssetSourceKind::Canvas => "canvas", - } -} - -pub(crate) fn truncate_prompt_context(value: &str) -> String { - const MAX_CHARS: usize = 2400; - let sanitized = sanitize_prompt_context(value); - let trimmed = sanitized.trim(); - let mut output = trimmed.chars().take(MAX_CHARS).collect::(); - if trimmed.chars().count() > MAX_CHARS { - output.push_str("\n..."); - } - output -} - -pub(crate) fn truncate_prompt_context_preserving_tail(value: &str) -> String { - const MAX_CHARS: usize = 2400; - let sanitized = sanitize_prompt_context(value); - let trimmed = sanitized.trim(); - if trimmed.chars().count() <= MAX_CHARS { - return trimmed.to_string(); - } - let mut characters = trimmed.chars().rev().take(MAX_CHARS).collect::>(); - characters.reverse(); - format!( - "...\n{}", - characters.into_iter().collect::() - ) -} - -pub(crate) fn sanitize_prompt_context(value: &str) -> String { - let mut sanitized = Vec::new(); - let mut inside_private_key = false; - for line in value.lines() { - let lower = line.to_ascii_lowercase(); - if inside_private_key { - if lower.contains("-----end") && lower.contains("private key") { - inside_private_key = false; - } - continue; - } - if lower.contains("-----begin") && lower.contains("private key") { - sanitized.push("[redacted sensitive context]".to_string()); - inside_private_key = true; - continue; - } - if lower.contains(".env") - || lower.contains("game-creator.config") - || lower.contains("authorization:") - || lower.contains("cookie:") - || lower.contains("api_key") - || lower.contains("apikey") - || lower.contains("api key") - || lower.contains("x-api-key") - || lower.contains("x_api_key") - || lower.contains("client_secret") - || lower.contains("clientsecret") - || lower.contains("access_token") - || lower.contains("accesstoken") - || lower.contains("refresh_token") - || lower.contains("refreshtoken") - || lower.contains("password=") - || lower.contains("password:") - || lower.contains("\"password\"") - || lower.contains("--password") - || lower.contains("--api-key") - || lower.contains("--apikey") - || lower.contains("--token") - || lower.contains("--secret") - || lower.contains("secret=") - || lower.contains("token=") - || lower.contains("\"token\"") - || lower.contains("bearer ") - { - sanitized.push("[redacted sensitive context]".to_string()); - } else { - sanitized.push(redact_secret_tokens(line)); - } - } - sanitized.join("\n") -} - -pub(crate) fn redact_secret_tokens(line: &str) -> String { - let mut spans = [ - ("tnr_sk_", 8usize), - ("sk-", 8), - ("ghp_", 20), - ("gho_", 20), - ("ghu_", 20), - ("ghs_", 20), - ("ghr_", 20), - ("npm_", 20), - ("AKIA", 16), - ("ASIA", 16), - ("AIza", 20), - ("sk_live_", 16), - ("rk_live_", 16), - ("xoxb-", 16), - ("xoxp-", 16), - ("xoxa-", 16), - ("xoxr-", 16), - ] - .into_iter() - .flat_map(|(prefix, minimum_body_length)| { - line.match_indices(prefix).filter_map(move |(index, _)| { - agent_runtime_secret_token_end_with_minimum(line, index, prefix, minimum_body_length) - .map(|token_end| (index, token_end)) - }) - }) - .collect::>(); - spans.extend(line.match_indices("eyJ").filter_map(|(index, _)| { - agent_runtime_jwt_token_end(line, index).map(|token_end| (index, token_end)) - })); - if spans.is_empty() { - return line.to_string(); - } - spans.sort_unstable_by_key(|(start, end)| (*start, *end)); - let mut output = String::with_capacity(line.len()); - let mut cursor = 0usize; - for (start, end) in spans { - if start < cursor { - continue; - } - output.push_str(&line[cursor..start]); - output.push_str("[redacted-secret]"); - cursor = end; - } - output.push_str(&line[cursor..]); - output -} - -pub(crate) fn read_optional_text(path: &Path) -> Result { - match fs::read_to_string(path) { - Ok(content) => Ok(content), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), - Err(error) => Err(format!("读取上下文失败:{}: {error}", path.display())), - } -} +mod tests; +mod trace; + +pub(in crate::agent) use canvas_generation::generate_platform_art_asset_with_options_at; +pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; +pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client; +pub(in crate::agent) use trace::game_creation_agent_group_id; + +#[allow(unused_imports)] +pub(crate) use canvas_generation::{ + build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at, + maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, + platform_art_asset_art_spec, platform_art_asset_output_extension_matches, + prepare_platform_art_asset_output_path, project_canvas_asset_media_types, + role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, +}; +#[allow(unused_imports)] +pub(crate) use draft_validation::{ + contains_any, evaluate_game_draft, extract_json_payload, fnv1a64, + parse_llm_game_draft_response, render_evaluator_findings, render_planner_spec, + strip_llm_thinking_blocks, validate_canvas_rendering_html, validate_game_html_smoke, + validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html, + validate_playable_game_html, validate_safe_game_html_runtime, +}; +pub(crate) use draft_writer::write_local_game_draft_at; +#[allow(unused_imports)] +pub(crate) use loop_orchestration::{ + emit_agent_progress, game_creator_agent_llm_error_public_summary, + request_game_creator_llm_text, request_generator_game_draft_with_client, + request_planner_spec_with_client, run_game_creator_agent_loop_at, AgentProgressEmitter, +}; +#[allow(unused_imports)] +pub(crate) use pass_artifacts::{ + agent_role_memory_relative_path, agent_role_memory_relative_path_for_task, + append_agent_success_memories, append_group_brief_steps, read_previous_agent_role_brief, + render_agent_pass_agenda_markdown, render_agent_pass_task_graph_json, + render_carryover_role_brief, write_agent_group_brief, write_agent_pass_agenda, + write_agent_pass_artifacts, write_agent_pass_file, write_agent_role_brief, +}; +#[allow(unused_imports)] +pub(crate) use prompt_context::{ + append_prompt_context, asset_source_kind_label, game_creator_generator_user_prompt, + game_creator_planner_system_prompt, game_creator_planner_user_prompt, + game_creator_system_prompt, read_optional_text, redact_secret_tokens, + render_local_asset_prompt_context, render_local_conversation_prompt_context, + render_local_conversation_prompt_context_for_session, sanitize_prompt_context, + truncate_prompt_context, truncate_prompt_context_preserving_tail, +}; +#[allow(unused_imports)] +pub(crate) use role_briefs::{ + append_agent_loop_log, append_agent_loop_memory, append_collaboration_steps, + game_creator_role_agent_system_prompt, handoff_group_label, handoff_summary_for_group, + has_game_creator_agent_llm_override, join_or_none, local_role_acceptance_risk, + local_role_downstream_constraint, render_agent_group_brief_markdown, + render_agent_group_briefs_context, render_agent_pass_handoff, render_agent_role_brief_context, + render_local_agent_role_brief, request_agent_group_briefs_with_client, + request_agent_role_brief_with_config, truncate_inline, +}; +#[allow(unused_imports)] +pub(crate) use run_lifecycle::{ + agent_run_control_result_from_trace, collect_agent_run_artifacts, control_agent_run_at, + count_agent_tool_calls, prune_agent_run_history, read_latest_agent_run_trace, + resumed_agent_run_prompt, update_agent_run_lifecycle, write_agent_run_trace_payload, +}; +#[cfg(test)] +pub(crate) use tests::request_llm_game_draft_with_client; +#[allow(unused_imports)] +pub(crate) use trace::{ + agent_run_lifecycle_status, agent_run_stop_reason, agent_trace_step, agent_trace_step_owned, + append_agent_run_activity, append_agent_run_jsonl, append_agent_run_output, + append_agent_run_trace_step, append_local_artifact_write_step, append_preview_log, + append_preview_start_trace_step, append_preview_stop_trace_step, + append_static_smoke_manual_trace_step, append_static_smoke_step, + build_agent_run_task_graph_trace, collect_agent_pass_plan_traces, + game_creation_agent_group_from_id, infer_agent_trace_phase, parse_agent_task_id_list, + read_agent_agenda_snapshot, ready_task_ids_for_tasks, record_replaced_preview_stop, + role_brief_completes_task, set_task_status_if_current, should_replace_task_status, status_rank, + task_has_status, task_status_from_agent_step, with_task_context, + write_agent_run_context_bundle, write_agent_run_trace, AgentAgendaSnapshot, + AgentPassTaskGraphSnapshot, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs new file mode 100644 index 000000000..08df7d4d1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -0,0 +1,571 @@ +use super::*; + +pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { + read_manifest_for_project(root) + .map(|manifest| { + manifest + .assets + .iter() + .filter(|asset| asset.source.kind == GameCreationAppAssetSourceKind::Canvas) + .map(|asset| asset.media_type.clone()) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn suggested_canvas_tool_call( + role_brief: &AgentRoleBrief, + input_paths: &[String], + canvas_asset_media_types: &[String], +) -> Option { + if role_brief.status != "completed" + || role_has_canvas_assets(role_brief, canvas_asset_media_types) + { + return None; + } + let tool_id = match ( + role_brief.group_definition.id, + role_brief.role_definition.id, + ) { + ("art", "asset") | ("audio", "sfx") => "agent.tool.suggest.canvas.project_sync", + _ => return None, + }; + Some(GameCreationAgentToolCallTrace { + tool_id: tool_id.to_string(), + status: "suggested".to_string(), + input_paths: input_paths.to_vec(), + output_paths: Vec::new(), + summary: "项目还没有对应类型的画板回流素材;建议用户确认 /sync-canvas-project <画板项目ID> 后同步画板资源到本地 assets/。" + .to_string(), + }) +} + +pub(crate) async fn maybe_generate_platform_art_asset_step( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + pass: u8, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Option { + if !needs_platform_art_asset_generation(root, briefs) || !editor_api_key_is_configured() { + return None; + } + emit_agent_progress( + progress, + "editor.image_generate", + "美术组正在通过平台 External Editor API 生成首版素材", + ); + let result = generate_platform_art_asset_at(root, prompt, briefs).await; + let (status, output_paths, summary) = match result { + Ok(generated) => ( + "completed", + vec![generated.asset.local_path.clone()], + format!( + "已通过平台 External Editor API 生成首版美术素材:{}", + generated.asset.local_path + ), + ), + Err(error) => ( + "failed", + Vec::new(), + format!("平台 External Editor API 生成首版美术素材失败:{error}"), + ), + }; + Some(with_task_context( + agent_trace_step_owned( + pass, + "美术组 / PlatformImageGenerate", + status, + vec![ + ".agent/spec.md".to_string(), + ".agent/manifest.json".to_string(), + ], + output_paths, + &summary, + "agent.tool.platform.editor_image_generate", + ), + "art", + "Asset", + Some("art-asset-plan"), + "asset-generation", + )) +} + +pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGroupBrief]) -> bool { + let canvas_asset_media_types = project_canvas_asset_media_types(root); + briefs.iter().any(|brief| { + brief.role_briefs.iter().any(|role_brief| { + role_brief.status == "completed" + && role_brief.group_definition.id == "art" + && role_brief.role_definition.id == "asset" + && !role_has_canvas_assets(role_brief, &canvas_asset_media_types) + }) + }) +} + +pub(crate) fn editor_api_key_is_configured() -> bool { + load_game_creator_app_config() + .ok() + .and_then(|config| trim_config_string(&config.editor_api.api_key)) + .is_some() +} + +pub(crate) fn role_has_canvas_assets(role_brief: &AgentRoleBrief, media_types: &[String]) -> bool { + match ( + role_brief.group_definition.id, + role_brief.role_definition.id, + ) { + ("art", "asset") => media_types.iter().any(|media_type| { + let media_type = media_type.as_str(); + media_type.starts_with("image/") + || media_type == "application/vnd.genarrative.image-sequence" + }), + ("audio", "sfx") => media_types + .iter() + .any(|media_type| media_type.starts_with("audio/")), + _ => false, + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformArtAssetGenerationOptions { + pub(crate) output_path: Option, + pub(crate) aspect_ratio: String, + pub(crate) image_size: String, + pub(crate) asset_kind: String, + pub(crate) asset_label: String, +} + +impl Default for PlatformArtAssetGenerationOptions { + fn default() -> Self { + Self { + output_path: None, + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "game-art".to_string(), + asset_label: "AI 游戏首版美术素材".to_string(), + } + } +} + +pub(crate) fn prepare_platform_art_asset_output_path( + root: &Path, + output_path: Option<&str>, +) -> Result, String> { + let Some(output_path) = output_path.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let normalized = normalize_relative_path(output_path)?; + if !normalized.starts_with("assets/") { + return Err("图片生成 outputPath 必须位于项目 assets/ 目录".to_string()); + } + let extension = Path::new(&normalized) + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if !matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "webp") { + return Err("图片生成 outputPath 只允许 png、jpg、jpeg 或 webp 文件".to_string()); + } + let absolute = resolve_local_project_path(root, &normalized)?; + if absolute.exists() { + return Err(format!( + "图片生成 outputPath 已存在,禁止静默覆盖:{normalized}" + )); + } + Ok(Some((normalized, absolute))) +} + +pub(crate) fn platform_art_asset_output_extension_matches( + output_path: &str, + generated_extension: &str, +) -> bool { + let requested = Path::new(output_path) + .extension() + .and_then(|value| value.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + requested == generated_extension + || matches!( + (requested.as_str(), generated_extension), + ("jpg", "jpeg") | ("jpeg", "jpg") + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExternalCanvasGenerationContext { + project_id: String, + asset_folder_id: String, + canvas_name: String, +} + +fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Value { + payload.get("data").unwrap_or(payload) +} + +async fn external_editor_json_request( + request: reqwest::RequestBuilder, + action: &str, +) -> Result { + let response = request + .send() + .await + .map_err(|error| format!("{action}失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("{action}失败:HTTP {}", status.as_u16())); + } + response + .json::() + .await + .map_err(|error| format!("解析{action}响应失败:{error}")) +} + +async fn prepare_external_canvas_generation_context( + root: &Path, + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, +) -> Result { + let manifest = read_manifest_for_project(root)?; + let canvas_name = manifest.name.trim().chars().take(80).collect::(); + let canvas_name = if canvas_name.is_empty() { + "未命名游戏原型".to_string() + } else { + canvas_name + }; + let projects_payload = external_editor_json_request( + client + .get(format!("{api_base_url}/api/external/v1/editor/projects")) + .bearer_auth(api_key), + "读取外部画布项目", + ) + .await?; + let projects = external_editor_response_data(&projects_payload) + .get("projects") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "外部画布项目响应缺少 projects".to_string())?; + let project_id = projects + .iter() + .find(|project| json_string_field(project, "title").as_deref() == Some(&canvas_name)) + .and_then(|project| json_string_field(project, "projectId")); + let project_id = match project_id { + Some(project_id) => project_id, + None => { + let payload = external_editor_json_request( + client + .post(format!("{api_base_url}/api/external/v1/editor/projects")) + .bearer_auth(api_key) + .json(&serde_json::json!({ "title": canvas_name })), + "创建外部画布项目", + ) + .await?; + external_editor_response_data(&payload) + .get("project") + .and_then(|project| json_string_field(project, "projectId")) + .ok_or_else(|| "创建外部画布项目响应缺少 projectId".to_string())? + } + }; + + let library_payload = external_editor_json_request( + client + .get(format!( + "{api_base_url}/api/external/v1/editor/assets/library" + )) + .bearer_auth(api_key), + "读取外部素材库", + ) + .await?; + let folders = external_editor_response_data(&library_payload) + .get("library") + .and_then(|library| library.get("folders")) + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "外部素材库响应缺少 library.folders".to_string())?; + let asset_folder_id = folders + .iter() + .find(|folder| json_string_field(folder, "label").as_deref() == Some(&canvas_name)) + .and_then(|folder| json_string_field(folder, "folderId")); + let asset_folder_id = match asset_folder_id { + Some(folder_id) => folder_id, + None => { + let payload = external_editor_json_request( + client + .post(format!( + "{api_base_url}/api/external/v1/editor/assets/folders" + )) + .bearer_auth(api_key) + .json(&serde_json::json!({ "label": canvas_name })), + "创建外部素材库目录", + ) + .await?; + external_editor_response_data(&payload) + .get("folder") + .and_then(|folder| json_string_field(folder, "folderId")) + .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())? + } + }; + + Ok(ExternalCanvasGenerationContext { + project_id, + asset_folder_id, + canvas_name, + }) +} + +fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Value { + let (width, height) = match aspect_ratio { + "16:9" => (1024, 576), + "9:16" => (576, 1024), + "3:2" => (1024, 683), + "2:3" => (683, 1024), + _ => (1024, 1024), + }; + serde_json::json!({ + "x": 0, + "y": 0, + "width": width, + "height": height, + "originalWidth": width, + "originalHeight": height, + }) +} + +pub(crate) async fn generate_platform_art_asset_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], +) -> Result { + generate_platform_art_asset_with_options_at( + root, + prompt, + briefs, + &PlatformArtAssetGenerationOptions::default(), + ) + .await +} + +pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; + let requested_output = + prepare_platform_art_asset_output_path(root, options.output_path.as_deref())?; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; + let client = reqwest::Client::new(); + let canvas_context = + prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?; + let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let generation_kind = if options.asset_kind == "ui-prototype" { + "ui-design" + } else { + "spec" + }; + let response = client + .post(format!( + "{}/api/external/v1/editor/images/generations", + api_base_url + )) + .bearer_auth(&api_key) + .json(&serde_json::json!({ + "prompt": generation_prompt, + "kind": generation_kind, + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetKind": options.asset_kind, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + })) + .send() + .await + .map_err(|error| format!("请求平台图片生成失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + } + let payload = response + .json::() + .await + .map_err(|error| format!("解析平台图片生成响应失败:{error}"))?; + let generated = payload.get("data").unwrap_or(&payload); + let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, generated) + .await? + .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; + let null = serde_json::Value::Null; + let resource = generated.get("resource").unwrap_or(&null); + let asset = generated.get("asset").unwrap_or(&null); + let resource_id = json_string_field(resource, "resourceId"); + let task_id = + json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); + let asset_object_id = json_string_field(generated, "assetObjectId") + .or_else(|| json_string_field(resource, "assetObjectId")) + .or_else(|| json_string_field(asset, "assetObjectId")); + let generated_prompt = json_string_field(generated, "actualPrompt") + .or_else(|| json_string_field(generated, "prompt")) + .or_else(|| json_string_field(resource, "actualPrompt")) + .or_else(|| json_string_field(resource, "prompt")); + let model = + json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); + let provider = json_string_field(generated, "provider") + .or_else(|| json_string_field(resource, "provider")); + let source_hint = json_string_field(generated, "objectKey") + .or_else(|| json_string_field(generated, "imageSrc")); + let extension = infer_file_extension(source_hint.as_deref(), &download.media_type); + let file_stem = resource_id + .as_deref() + .or(task_id.as_deref()) + .unwrap_or("platform-art"); + let (local_path, mut absolute_path) = match requested_output { + Some((local_path, absolute_path)) => { + if !platform_art_asset_output_extension_matches(&local_path, &extension) { + return Err(format!( + "图片生成结果格式为 {extension},与 outputPath 扩展名不一致" + )); + } + (local_path, absolute_path) + } + None => { + let local_path = format!( + "assets/canvas-generated/{}-{}.{}", + unix_millis(), + sanitize_file_name(file_stem), + extension + ); + let absolute_path = resolve_local_project_path(root, &local_path)?; + (local_path, absolute_path) + } + }; + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?; + } + absolute_path = resolve_local_project_path(root, &local_path)?; + let mut output = fs::OpenOptions::new(); + output.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + output.custom_flags(libc::O_NOFOLLOW); + output.mode(0o600); + } + let mut output = output + .open(&absolute_path) + .map_err(|error| format!("创建平台生成素材失败:{}: {error}", absolute_path.display()))?; + output.write_all(&download.bytes).map_err(|error| { + let _ = fs::remove_file(&absolute_path); + format!("写入平台生成素材失败:{}: {error}", absolute_path.display()) + })?; + drop(output); + let canvas_project_id = json_string_field(resource, "projectId") + .or_else(|| json_string_field(generated, "projectId")) + .or_else(|| Some(canvas_context.project_id.clone())); + let registered = match register_local_asset_entry( + root, + &local_path, + &options.asset_kind, + &download.media_type, + "platform-art", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id, + resource_id: resource_id.clone(), + asset_object_id: asset_object_id.clone(), + task_id: task_id.clone(), + prompt: generated_prompt.clone(), + model: model.clone(), + }, + ) { + Ok(registered) => registered, + Err(error) => { + let _ = fs::remove_file(&absolute_path); + return Err(error); + } + }; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "canvas.asset_generate", + "assetId": registered.id.clone(), + "localPath": registered.local_path.clone(), + "resourceId": resource_id.clone(), + "assetObjectId": asset_object_id.clone(), + "taskId": task_id.clone(), + "model": model.clone(), + "provider": provider.clone(), + "assetFolderId": canvas_context.asset_folder_id, + "canvasName": canvas_context.canvas_name, + }), + )?; + Ok(GeneratedPlatformArtAsset { + asset: registered, + resource_id, + asset_object_id, + task_id, + model, + }) +} + +pub(crate) fn platform_art_asset_art_spec( + options: &PlatformArtAssetGenerationOptions, +) -> serde_json::Value { + if options.asset_kind == "ui-prototype" { + return serde_json::json!({ + "assetType": "ui", + "subject": "完整桌面端游戏 UI 原型,包含 HUD、卡牌控件、战场区和操作控件", + "style": "正视角、清晰分区、可指导 HTML/CSS 实现的高保真 UI/UX mockup", + "palette": "与原创游戏主题一致,文字与控件对比清楚", + "composition": "严格 16:9 单屏界面;顶部资源与波次 HUD,左侧或顶部单位卡槽,中部战场网格,右侧敌人入口,底部或角落放置开始、暂停、重开和操作提示", + "format": format!("{} {}", options.aspect_ratio, options.image_size), + "constraints": "必须明显展示资源数值、单位卡牌、冷却/费用、波次进度、开始或暂停或重开控件和操作反馈;不得只生成无 HUD 的场景插画、战斗概念图、地图或宣传图;不得复刻现有游戏角色、Logo、贴图或受保护视觉语言", + "references": [], + }); + } + serde_json::json!({ + "assetType": "art", + "subject": options.asset_label, + "style": "与当前游戏需求一致的可落地首版视觉", + "composition": format!("{} 游戏素材", options.aspect_ratio), + "format": format!("{} {}", options.aspect_ratio, options.image_size), + "constraints": "必须是可见的真实图片产物,不得用纯文本计划代替", + "references": [], + }) +} + +pub(crate) fn build_platform_art_asset_prompt( + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, +) -> String { + if options.asset_kind == "ui-prototype" { + return format!( + "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。画面必须是完整 16:9 桌面端单屏界面,明确可见:顶部资源数值与波次/状态 HUD;单位卡牌及费用、冷却状态;中部战场网格;右侧敌人来袭方向;开始、暂停、重开控件;基础操作提示和点击/资源不足等反馈。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画草地、角色和敌人的无 HUD 战斗画面,禁止做海报、地图或纯插画。保持原创主题,不使用现有游戏角色、Logo、贴图或受保护视觉语言。\n\n项目 UI 需求:{}", + truncate_prompt_context(prompt.trim()) + ); + } + let art_asset_brief = briefs + .iter() + .flat_map(|brief| brief.role_briefs.iter()) + .find(|role_brief| { + role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset" + }) + .map(|role_brief| role_brief.markdown.trim()) + .filter(|markdown| !markdown.is_empty()) + .unwrap_or("需要一张可直接用于 Web 小游戏首版原型的核心美术素材。"); + format!( + "为 Web 小游戏首版原型生成一张核心美术素材,适合放入本地 assets 并被 canvas 游戏直接引用。\n用户需求:{}\n美术资产 brief:{}", + truncate_inline(prompt, 240), + truncate_prompt_context(art_asset_brief) + ) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs new file mode 100644 index 000000000..e2d726e00 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_validation.rs @@ -0,0 +1,394 @@ +use super::*; + +pub(crate) fn evaluate_game_draft(prompt: &str, draft: &LlmGameDraft) -> Vec { + let mut issues = Vec::new(); + if let Err(error) = validate_llm_game_draft(prompt, draft) { + issues.push(error); + } + let html = draft.game_html.to_ascii_lowercase(); + if !["keydown", "keyup", "pointer", "mousedown", "touch", "click"] + .iter() + .any(|needle| html.contains(needle)) + { + issues.push("gameHtml 缺少明确的键盘、鼠标或触摸输入监听".to_string()); + } + issues +} + +pub(crate) fn render_planner_spec(prompt: &str, spec: &str) -> String { + format!( + "# Planner Spec\n\n## 用户需求\n\n{}\n\n## 规格\n\n{}\n", + prompt.trim(), + spec.trim() + ) +} + +pub(crate) fn render_evaluator_findings(pass: u8, issues: &[impl AsRef]) -> String { + let issue_values = issues + .iter() + .map(|issue| issue.as_ref().trim()) + .filter(|issue| !issue.is_empty()) + .collect::>(); + let actionable_issues = issue_values + .iter() + .filter(|issue| !issue.contains("暂无上一轮问题")) + .map(|issue| (*issue).to_string()) + .collect::>(); + let mut output = format!( + "# Evaluator Findings\n\n- pass: {pass}\n- status: {}\n\n", + if issue_values.is_empty() { + "passed" + } else { + "needs-revision" + } + ); + if issue_values.is_empty() { + output + .push_str("## 结果\n\n- 本地静态验收通过:HTML 自包含、包含 canvas、主循环和输入。\n"); + } else { + output.push_str("## 问题\n\n"); + for issue in &issue_values { + output.push_str("- "); + output.push_str(issue); + output.push('\n'); + } + } + output.push_str("\n## Repair Routes\n\n```json\n"); + if actionable_issues.is_empty() { + output.push_str("[]"); + } else { + let routes = build_game_creation_seed_task_graph("AI 游戏创作") + .map(|graph| route_game_creation_repair_issues(&graph, &actionable_issues)) + .unwrap_or_default(); + match serde_json::to_string_pretty(&routes) { + Ok(payload) => output.push_str(&payload), + Err(_) => output.push_str("[]"), + } + } + output.push_str("\n```\n"); + output +} + +pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +pub(crate) fn parse_llm_game_draft_response(content: &str) -> Result { + let content = strip_llm_thinking_blocks(content); + let payload = extract_json_payload(content.as_str()) + .ok_or_else(|| "LLM 返回不是 JSON 对象".to_string())?; + serde_json::from_str::(payload) + .map_err(|error| format!("解析 LLM 游戏草案失败:{error}")) +} + +pub(crate) fn strip_llm_thinking_blocks(content: &str) -> String { + let mut output = String::new(); + let mut rest = content; + loop { + let Some(start) = rest.to_ascii_lowercase().find("") else { + output.push_str(rest); + break; + }; + output.push_str(&rest[..start]); + let after_start = &rest[start + "".len()..]; + let Some(end) = after_start.to_ascii_lowercase().find("") else { + break; + }; + rest = &after_start[end + "".len()..]; + } + output.trim().to_string() +} + +pub(crate) fn extract_json_payload(content: &str) -> Option<&str> { + let trimmed = content.trim(); + let without_fence = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .and_then(|value| value.strip_suffix("```")) + .map(str::trim) + .unwrap_or(trimmed); + let start = without_fence.find('{')?; + let mut depth = 0usize; + let mut inside_string = false; + let mut escaped = false; + for (offset, character) in without_fence[start..].char_indices() { + if inside_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + inside_string = false; + } + continue; + } + match character { + '"' => inside_string = true, + '{' => depth += 1, + '}' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + let end = start + offset + character.len_utf8(); + return Some(&without_fence[start..end]); + } + } + _ => {} + } + } + None +} + +pub(crate) fn validate_llm_game_draft(prompt: &str, draft: &LlmGameDraft) -> Result<(), String> { + if draft.title.trim().is_empty() { + return Err("LLM 草案缺少标题".to_string()); + } + if draft.design_markdown.trim().is_empty() { + return Err("LLM 草案缺少设计说明".to_string()); + } + if !draft.balance.is_object() { + return Err("LLM 草案 balance 必须是 JSON object".to_string()); + } + if !draft.art_manifest.is_object() { + return Err("LLM 草案 artManifest 必须是 JSON object".to_string()); + } + if !draft.audio_manifest.is_object() { + return Err("LLM 草案 audioManifest 必须是 JSON object".to_string()); + } + if draft.publish_readme.trim().is_empty() { + return Err("LLM 草案缺少发布说明".to_string()); + } + if draft.handoff_summary.trim().is_empty() { + return Err("LLM 草案缺少多智能体交接摘要".to_string()); + } + validate_llm_agent_handoffs(draft)?; + + let html = draft.game_html.trim(); + let lower_html = html.to_ascii_lowercase(); + if !lower_html.contains("')) && html.contains(prompt) { + return Err("LLM 草案 gameHtml 包含未转义的用户输入".to_string()); + } + validate_playable_game_html(html, "LLM 草案 gameHtml")?; + validate_non_placeholder_game_html(html, "LLM 草案 gameHtml")?; + + Ok(()) +} + +pub(crate) fn validate_playable_game_html(html: &str, label: &str) -> Result<(), String> { + let lower_html = html.to_ascii_lowercase(); + if !contains_any( + &lower_html, + &[ + "目标", + "任务", + "goal", + "objective", + "点亮", + "收集", + "抵达", + "获胜", + "通关", + "连击", + "combo", + "survive", + ], + ) { + return Err(format!("{label} 必须展示明确目标")); + } + if !contains_any( + &lower_html, + &[ + "胜利", + "获胜", + "失败", + "game over", + "win", + "lose", + "victory", + "defeat", + ], + ) { + return Err(format!("{label} 必须包含失败或胜利状态")); + } + if !contains_any( + &lower_html, + &["重开", "重新开始", "restart", "reset", "again", "再来"], + ) { + return Err(format!("{label} 必须包含重开路径")); + } + Ok(()) +} + +pub(crate) fn validate_safe_game_html_runtime(html: &str, label: &str) -> Result<(), String> { + let lower_html = html.to_ascii_lowercase(); + if lower_html.contains("".to_string()); + } + let opening_end = lower_html[opening..] + .find('>') + .map(|offset| opening + offset + 1) + .ok_or_else(|| "游戏入口的 结束标签未闭合".to_string())?; + } + if next_script_tag(lower_html, cursor, true).is_some() { + return Err("游戏入口包含没有对应开始标签的 ".to_string()); + } + Ok(()) +} + +pub(crate) fn validate_canvas_rendering_html(html: &str, label: &str) -> Result<(), String> { + let lower_html = html.to_ascii_lowercase(); + if !lower_html.contains("getcontext(") && !lower_html.contains(".getcontext") { + return Err(format!("{label} 必须获取 canvas 渲染上下文")); + } + if !contains_any( + &lower_html, + &[ + "fillrect(", + "strokerect(", + "drawimage(", + "filltext(", + ".arc(", + ".fill(", + ".stroke(", + "putimagedata(", + "drawarrays(", + "drawelements(", + ], + ) { + return Err(format!("{label} 必须在 canvas 上绘制画面")); + } + Ok(()) +} + +pub(crate) fn validate_non_placeholder_game_html(html: &str, label: &str) -> Result<(), String> { + let lower_html = html.to_ascii_lowercase(); + for forbidden in [ + "星核传送门", + "点击按钮加分", + "点击按钮得分", + "todo:", + "待实现", + "这里省略", + ] { + if lower_html.contains(forbidden) { + return Err(format!("{label} 不能是固定模板或未完成实现:{forbidden}")); + } + } + let compact = lower_html + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + if compact.contains("addeventlistener(") + && (compact.contains("=>{})") || compact.contains("function(){}")) + { + return Err(format!("{label} 的输入监听不能是空实现")); + } + Ok(()) +} + +pub(crate) fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + +pub(crate) fn validate_llm_agent_handoffs(draft: &LlmGameDraft) -> Result<(), String> { + const REQUIRED_GROUPS: [&str; 6] = ["design", "balance", "art", "audio", "code", "publishing"]; + for required_group in REQUIRED_GROUPS { + let handoff = draft + .handoffs + .iter() + .find(|handoff| handoff.group.trim() == required_group) + .ok_or_else(|| format!("LLM 草案 handoffs 缺少 {required_group} 专业组"))?; + if handoff.role.trim().is_empty() + || handoff.summary.trim().is_empty() + || handoff.next.trim().is_empty() + || handoff + .outputs + .iter() + .all(|output| output.trim().is_empty()) + { + return Err(format!("LLM 草案 handoffs.{required_group} 交接内容不完整")); + } + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs new file mode 100644 index 000000000..b3a84948e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs @@ -0,0 +1,136 @@ +use super::*; + +pub(crate) fn write_local_game_draft_at( + root: &Path, + prompt: &str, + draft: &LlmGameDraft, +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("创作想法不能为空".to_string()); + } + validate_llm_game_draft(prompt, draft)?; + init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; + let checkpoint = create_local_project_checkpoint_at(root)?; + let timestamp = unix_timestamp(); + let title = draft.title.trim(); + let handoff_summary = draft.handoff_summary.trim(); + let design_path = root.join("game/game_design.md"); + let balance_path = root.join("game/balance.json"); + let art_manifest_path = root.join("assets/manifest.art.json"); + let audio_manifest_path = root.join("assets/manifest.audio.json"); + let publish_readme_path = root.join("exports/README.md"); + let agent_log_path = root.join(".agent/logs/agent.log"); + let short_memory_path = root.join("memory/session.md"); + let long_memory_path = root.join("memory/project.md"); + let game_index_path = root.join("game/index.html"); + + append_markdown_entry( + &short_memory_path, + "# 短期记忆\n\n", + &format!("- {timestamp}: {prompt}\n"), + "写入短期记忆失败", + )?; + append_markdown_entry( + &long_memory_path, + "# 项目长期记忆\n\n## 当前约束\n\n- Web 小游戏原型\n- 本地 HTTP 预览\n\n## 创作目标记录\n\n", + &format!("- {timestamp}: {prompt}\n"), + "写入长期记忆失败", + )?; + fs::write( + &design_path, + format!( + "# 游戏设计草案\n\n## 原始想法\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n## LLM 生成草案\n\n{}\n", + draft.design_markdown.trim() + ), + ) + .map_err(|error| format!("写入游戏设计失败:{}: {error}", design_path.display()))?; + fs::write( + &balance_path, + serde_json::to_string_pretty(&draft.balance) + .map_err(|error| format!("生成数值配置失败:{error}"))?, + ) + .map_err(|error| format!("写入数值配置失败:{}: {error}", balance_path.display()))?; + fs::write( + &art_manifest_path, + serde_json::to_string_pretty(&draft.art_manifest) + .map_err(|error| format!("生成美术清单失败:{error}"))?, + ) + .map_err(|error| format!("写入美术清单失败:{}: {error}", art_manifest_path.display()))?; + fs::write( + &audio_manifest_path, + serde_json::to_string_pretty(&draft.audio_manifest) + .map_err(|error| format!("生成音乐音效清单失败:{error}"))?, + ) + .map_err(|error| { + format!( + "写入音乐音效清单失败:{}: {error}", + audio_manifest_path.display() + ) + })?; + fs::write( + &publish_readme_path, + format!( + "# 发布包装草案\n\n## 标题\n\n{title}\n\n## 简介\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n{}\n", + draft.publish_readme.trim() + ), + ) + .map_err(|error| { + format!( + "写入发布包装草案失败:{}: {error}", + publish_readme_path.display() + ) + })?; + + fs::write(&game_index_path, draft.game_html.trim()) + .map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?; + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&agent_log_path) + .and_then(|mut file| { + file.write_all( + format!("{timestamp} game.generate_draft llm\n{handoff_summary}\n").as_bytes(), + ) + }) + .map_err(|error| format!("写入 Agent 日志失败:{}: {error}", agent_log_path.display()))?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "game.generate_draft", + "goal": prompt, + "title": title, + "paths": [ + "memory/session.md", + "memory/project.md", + "game/game_design.md", + "game/balance.json", + "assets/manifest.art.json", + "assets/manifest.audio.json", + "exports/README.md", + "game/index.html", + ], + }), + )?; + let manifest = record_draft_task_progress(root, prompt, timestamp, &agent_log_path)?; + let diff = diff_local_project_checkpoint_at(root, &checkpoint.checkpoint_id)?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.diff", + "checkpointId": checkpoint.checkpoint_id, + "added": diff.added.len(), + "changed": diff.changed.len(), + "deleted": diff.deleted.len(), + }), + )?; + + Ok(GenerateLocalGameDraftResult { + project_path: root.to_string_lossy().into_owned(), + game_index_path: game_index_path.to_string_lossy().into_owned(), + design_path: design_path.to_string_lossy().into_owned(), + short_memory_path: short_memory_path.to_string_lossy().into_owned(), + long_memory_path: long_memory_path.to_string_lossy().into_owned(), + manifest, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs new file mode 100644 index 000000000..ecc223456 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -0,0 +1,536 @@ +use super::*; + +pub(crate) struct AgentProgressEmitter<'a> { + app: &'a tauri::AppHandle, + project_path: String, +} + +impl<'a> AgentProgressEmitter<'a> { + pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self { + Self { + app, + project_path: project_path.to_string(), + } + } + + fn emit(&self, stage: &str, message: &str) { + let _ = self.app.emit( + "game-creator-agent-progress", + GameCreatorAgentProgressEvent { + project_path: self.project_path.clone(), + stage: stage.to_string(), + message: message.to_string(), + }, + ); + } +} + +pub(crate) fn emit_agent_progress( + progress: Option<&AgentProgressEmitter<'_>>, + stage: &str, + message: &str, +) { + if let Some(progress) = progress { + progress.emit(stage, message); + } +} + +pub(crate) async fn run_game_creator_agent_loop_at( + root: &Path, + app_config: &GameCreatorAppConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let spec_path = root.join(".agent/spec.md"); + let findings_path = root.join(".agent/findings.md"); + let run_id = format!("game-generate-draft-{}", unix_millis()); + let mut steps = Vec::new(); + let planner_llm = resolve_game_creator_llm_config_for_agent(app_config, "planner"); + let planner_client = + build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?; + let planner_spec = request_planner_spec_with_client( + &planner_client, + &planner_llm, + prompt, + short_memory, + long_memory, + project_blackboard, + ) + .await + .map(|spec| render_planner_spec(prompt, &spec))?; + fs::write(&spec_path, &planner_spec) + .map_err(|error| format!("写入 Planner 规格失败:{}: {error}", spec_path.display()))?; + emit_agent_progress(progress, "llm.planner.done", "Planner 规格已生成"); + steps.push(with_task_context( + agent_trace_step( + 0, + "Planner", + "completed", + &[ + "memory/session.md", + "memory/project.md", + PROJECT_BLACKBOARD_MEMORY_PATH, + ".agent/conversations/project.jsonl", + ".agent/conversations/agents/", + ".agent/manifest.json", + ], + &[".agent/spec.md"], + "完成玩法规格和专业组分工", + "llm.chat.planner", + ), + "design", + "Director", + Some("design-director"), + "planning", + )); + + let mut latest_findings = + render_evaluator_findings(0, &["暂无上一轮问题,Generator 可开始首轮实现。"]); + fs::write(&findings_path, &latest_findings).map_err(|error| { + format!( + "写入 Evaluator 结果失败:{}: {error}", + findings_path.display() + ) + })?; + steps.push(agent_trace_step( + 0, + "Evaluator", + "waiting", + &[], + &[".agent/findings.md"], + "初始化评估反馈文件", + "file.write.findings", + )); + write_agent_run_trace(root, &run_id, prompt, "running", 0, &steps, None)?; + + let mut last_error = "Evaluator 未产出可用结果".to_string(); + for pass in 1..=GAME_CREATOR_AGENT_LOOP_MAX_PASSES { + emit_agent_progress( + progress, + "agent.orchestrator", + &format!("Orchestrator 正在规划第 {pass} 轮任务图"), + ); + let spec_markdown = read_optional_text(&spec_path)?; + let findings_markdown = read_optional_text(&findings_path)?; + let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; + let agenda = write_agent_pass_agenda(root, pass, &findings_markdown)?; + steps.push(agent_trace_step_owned( + pass, + "Orchestrator", + "completed", + vec![ + ".agent/spec.md".to_string(), + ".agent/findings.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/manifest.json".to_string(), + ], + vec![ + agenda.relative_path.clone(), + agenda.task_graph_relative_path.clone(), + ], + &format!( + "{};waves={};repairFocus={};repairRoutes={};carried={}", + agenda.summary, + agenda.dependency_waves.len(), + agenda.repair_focus.len(), + agenda.repair_routes.len(), + agenda.carried_task_ids.len() + ), + "agent.task_graph.plan_pass", + )); + let group_briefs = match request_agent_group_briefs_with_client( + root, + app_config, + prompt, + short_memory, + long_memory, + &project_blackboard, + &spec_markdown, + &findings_markdown, + &agenda, + &run_id, + pass, + ) + .await + { + Ok(briefs) => briefs, + Err(error) => { + let issues = vec![format!("专业组协作输出不可用:{error}")]; + steps.push(agent_trace_step( + pass, + "专业组协作", + "failed", + &[".agent/spec.md", ".agent/findings.md"], + &[], + &issues[0], + "agent.role.brief", + )); + latest_findings = render_evaluator_findings(pass, &issues); + fs::write(&findings_path, &latest_findings).map_err(|write_error| { + format!( + "写入 Evaluator 结果失败:{}: {write_error}", + findings_path.display() + ) + })?; + steps.push(with_task_context( + agent_trace_step( + pass, + "Evaluator", + "needs-revision", + &[], + &[".agent/findings.md"], + "记录专业组协作失败原因", + "file.write.findings", + ), + "code", + "Review", + Some("quality-review"), + "evaluation", + )); + last_error = issues.join(";"); + write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; + continue; + } + }; + emit_agent_progress( + progress, + "agent.role_briefs", + &format!("6 组角色 brief 已完成,第 {pass} 轮交给 Generator"), + ); + let platform_art_step = + maybe_generate_platform_art_asset_step(root, prompt, &group_briefs, pass, progress) + .await; + append_group_brief_steps(root, pass, &agenda.relative_path, &group_briefs, &mut steps); + if let Some(step) = platform_art_step { + steps.push(step); + } + let group_briefs_markdown = append_prompt_context( + &render_local_conversation_prompt_context(root, Some("*"))?, + &render_agent_group_briefs_context(&group_briefs), + ); + let group_briefs_markdown = append_prompt_context( + &group_briefs_markdown, + &render_local_asset_prompt_context(root)?, + ); + emit_agent_progress( + progress, + "llm.generator", + &format!("Generator 正在调用 LLM 生成第 {pass} 轮可运行草案"), + ); + let generator_llm = resolve_game_creator_llm_config_for_agent(app_config, "generator"); + let generator_client = + build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?; + match request_generator_game_draft_with_client( + &generator_client, + &generator_llm, + prompt, + short_memory, + long_memory, + &project_blackboard, + &spec_markdown, + &findings_markdown, + &group_briefs_markdown, + &read_optional_text(&root.join(&agenda.relative_path))?, + progress, + ) + .await + { + Ok(draft) => { + emit_agent_progress( + progress, + "llm.generator.done", + &format!("Generator 第 {pass} 轮草案已返回,Evaluator 开始质量评审"), + ); + let pass_artifacts = write_agent_pass_artifacts(root, pass, &draft)?; + let mut generator_input_paths = vec![ + "memory/session.md".to_string(), + "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/conversations/agents/".to_string(), + ".agent/manifest.json".to_string(), + ".agent/spec.md".to_string(), + ".agent/findings.md".to_string(), + agenda.relative_path.clone(), + agenda.task_graph_relative_path.clone(), + ]; + generator_input_paths + .extend(group_briefs.iter().map(|brief| brief.relative_path.clone())); + steps.push(agent_trace_step_owned( + pass, + "Generator", + "completed", + generator_input_paths, + vec![pass_artifacts.draft_json.clone()], + "生成结构化游戏草案", + "llm.chat.generator", + )); + append_collaboration_steps(pass, &draft, &pass_artifacts, &mut steps); + let issues = evaluate_game_draft(prompt, &draft); + latest_findings = render_evaluator_findings(pass, &issues); + fs::write(&findings_path, &latest_findings).map_err(|error| { + format!( + "写入 Evaluator 结果失败:{}: {error}", + findings_path.display() + ) + })?; + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "Evaluator", + if issues.is_empty() { + "passed" + } else { + "needs-revision" + }, + vec![ + pass_artifacts.game_html.clone(), + pass_artifacts.design_markdown.clone(), + pass_artifacts.balance_json.clone(), + pass_artifacts.art_manifest_json.clone(), + pass_artifacts.audio_manifest_json.clone(), + pass_artifacts.publish_readme.clone(), + ], + vec![".agent/findings.md".to_string()], + if issues.is_empty() { + "质量评审通过:玩法、资产、数值、程序和发布包装可进入预览试玩" + } else { + "质量评审发现问题,要求下一轮 Generator 修复" + }, + "evaluator.quality_review", + ), + "code", + "Review", + Some("quality-review"), + "evaluation", + )); + if issues.is_empty() { + emit_agent_progress( + progress, + "evaluator.passed", + &format!("Evaluator 第 {pass} 轮质量评审通过"), + ); + append_agent_success_memories(root, pass, &draft, &group_briefs)?; + write_agent_run_trace(root, &run_id, prompt, "passed", pass, &steps, None)?; + return Ok(GameCreatorAgentLoopResult { + run_id, + draft, + spec_markdown, + findings_markdown: latest_findings, + passes: pass, + steps, + }); + } + last_error = issues.join(";"); + emit_agent_progress( + progress, + "evaluator.needs_revision", + &format!("Evaluator 第 {pass} 轮要求返工:{last_error}"), + ); + write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; + } + Err(error) => { + let issues = vec![format!("Generator 输出不可用:{error}")]; + steps.push(agent_trace_step( + pass, + "Generator", + "failed", + &[".agent/spec.md", ".agent/findings.md"], + &[], + &issues[0], + "llm.chat.generator", + )); + latest_findings = render_evaluator_findings(pass, &issues); + fs::write(&findings_path, &latest_findings).map_err(|write_error| { + format!( + "写入 Evaluator 结果失败:{}: {write_error}", + findings_path.display() + ) + })?; + steps.push(with_task_context( + agent_trace_step( + pass, + "Evaluator", + "needs-revision", + &[], + &[".agent/findings.md"], + "记录 Generator 失败原因", + "file.write.findings", + ), + "code", + "Review", + Some("quality-review"), + "evaluation", + )); + last_error = issues.join(";"); + write_agent_run_trace(root, &run_id, prompt, "needs-revision", pass, &steps, None)?; + } + } + } + + let final_error = format!( + "Agent loop 已重试 {GAME_CREATOR_AGENT_LOOP_MAX_PASSES} 轮但仍未通过 Evaluator:{last_error}" + ); + write_agent_run_trace( + root, + &run_id, + prompt, + "failed", + GAME_CREATOR_AGENT_LOOP_MAX_PASSES, + &steps, + Some(&final_error), + )?; + Err(final_error) +} + +pub(crate) async fn request_planner_spec_with_client( + client: &LlmClient, + llm: &GameCreatorLlmConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, +) -> Result { + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_planner_system_prompt()), + LlmMessage::user(game_creator_planner_user_prompt( + prompt, + short_memory, + long_memory, + project_blackboard, + )), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS), + llm, + )?; + let response = request_game_creator_llm_text(client, llm, request) + .await + .map_err(|error| format!("Planner 生成失败:{error}"))?; + let spec = strip_llm_thinking_blocks(response.text.as_str()); + if spec.is_empty() { + Err("Planner 未返回规格".to_string()) + } else { + Ok(spec) + } +} + +pub(crate) async fn request_generator_game_draft_with_client( + client: &LlmClient, + llm: &GameCreatorLlmConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + spec_markdown: &str, + findings_markdown: &str, + group_briefs_markdown: &str, + agenda_markdown: &str, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let system_prompt = game_creator_system_prompt(); + let user_prompt = game_creator_generator_user_prompt( + prompt, + short_memory, + long_memory, + project_blackboard, + spec_markdown, + findings_markdown, + group_briefs_markdown, + agenda_markdown, + ); + // deepseek-v4-pro 等推理模型偶发返回空 content(HTTP 200、completion_tokens=0, + // 参见 DeepSeek-V3 issue #1453)。这类空返回是上游瞬时故障,原样重发通常即可恢复, + // 因此仅对 EmptyResponse 最多重试 3 次(含首次共 4 次请求),其它错误不重试。 + const MAX_EMPTY_RETRIES: u32 = 3; + let mut empty_retries = 0u32; + let response = loop { + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt.clone()), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS), + llm, + )?; + match request_game_creator_llm_text(client, llm, request).await { + Ok(response) => break response, + Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { + empty_retries += 1; + eprintln!( + "llm.chat.generator.empty-response 重试 {empty_retries}/{MAX_EMPTY_RETRIES}(上游返回空 content,原样重发)" + ); + // 同步推送到 App 进度面板,便于在界面上看到重试(无需盯命令行)。 + emit_agent_progress( + progress, + "llm.generator.empty_retry", + &format!( + "Generator 收到空返回(DeepSeek 偶发),正在自动重试 {empty_retries}/{MAX_EMPTY_RETRIES}" + ), + ); + continue; + } + Err(error) => { + // 调用失败(含空返回重试耗尽)时,把本次输入连同错误一并落盘,便于复现定位(仅 debug 构建)。 + #[cfg(all(debug_assertions, not(test)))] + debug::persist_error_input(system_prompt, user_prompt.as_str(), &error.to_string()); + return Err(format!("LLM 生成失败:{error}")); + } + } + }; + // 先把原始返回落盘,再解析;解析失败(如输出截断)时仍能从仓库里拿到完整原文(仅 debug 构建)。 + #[cfg(all(debug_assertions, not(test)))] + debug::persist_snapshot(response.text.as_str()); + let content = strip_llm_thinking_blocks(response.text.as_str()); + parse_llm_game_draft_response(content.as_str()) +} + +pub(crate) async fn request_game_creator_llm_text( + client: &LlmClient, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, +) -> Result { + if llm.stream { + client.stream_run(request, |_| {}).await + } else { + client.run(request).await + } +} + +pub(in crate::agent) fn build_game_creator_agent_runtime_llm_client( + llm: &GameCreatorLlmConfig, + config_path: &str, +) -> Result { + let mut single_attempt = llm.clone(); + single_attempt.max_retries = 0; + build_game_creator_llm_client_without_redirects_from_llm_config(&single_attempt, config_path) +} + +pub(crate) fn game_creator_agent_llm_error_public_summary( + error: &platform_llm::LlmError, +) -> String { + let kind = match error { + platform_llm::LlmError::Timeout { .. } => "timeout".to_string(), + platform_llm::LlmError::Connectivity { .. } => "connectivity".to_string(), + platform_llm::LlmError::Transport(_) => "transport".to_string(), + platform_llm::LlmError::Upstream { status_code, .. } => { + format!("upstream-{status_code}") + } + platform_llm::LlmError::InvalidConfig(_) => "invalid-config".to_string(), + platform_llm::LlmError::InvalidRequest(_) => "invalid-request".to_string(), + platform_llm::LlmError::StreamUnavailable => "stream-unavailable".to_string(), + platform_llm::LlmError::EmptyResponse => "empty-response".to_string(), + platform_llm::LlmError::Deserialize(_) => "deserialize".to_string(), + }; + let raw = error.to_string(); + format!( + "kind={kind} fingerprint={:x} chars={}", + Sha256::digest(raw.as_bytes()), + raw.chars().count() + ) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs new file mode 100644 index 000000000..a662ba9dc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs @@ -0,0 +1,471 @@ +use super::*; + +pub(crate) fn write_agent_pass_agenda( + root: &Path, + pass: u8, + findings_markdown: &str, +) -> Result { + let graph = build_game_creation_seed_task_graph("AI 游戏创作") + .map_err(|error| format!("构建 Agent 编排任务图失败:{error}"))?; + let pass_plan = plan_game_creation_agent_pass(&graph, pass, findings_markdown); + let repair_routes = pass_plan + .repair_routes + .iter() + .map(|route| GameCreationAgentRepairRouteTrace { + issue: route.issue.clone(), + task_ids: route.task_ids.clone(), + reason: route.reason.clone(), + }) + .collect::>(); + let relative_path = format!(".agent/passes/pass-{pass}/agenda.md"); + let markdown = render_agent_pass_agenda_markdown( + pass, + &pass_plan.mode, + &pass_plan.active_task_ids, + &pass_plan.carried_task_ids, + &pass_plan.dependency_waves, + &pass_plan.repair_focus, + &repair_routes, + ); + write_agent_pass_file(root, &relative_path, &markdown)?; + let task_graph_relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); + write_agent_pass_file( + root, + &task_graph_relative_path, + &render_agent_pass_task_graph_json( + pass, + &pass_plan.mode, + &pass_plan.summary, + &pass_plan.active_task_ids, + &pass_plan.carried_task_ids, + &pass_plan.dependency_waves, + &pass_plan.repair_focus, + &repair_routes, + )?, + )?; + Ok(AgentPassAgenda { + relative_path, + task_graph_relative_path, + active_task_ids: pass_plan.active_task_ids, + carried_task_ids: pass_plan.carried_task_ids, + dependency_waves: pass_plan.dependency_waves, + repair_focus: pass_plan.repair_focus, + repair_routes, + summary: pass_plan.summary, + }) +} + +pub(crate) fn render_agent_pass_agenda_markdown( + pass: u8, + mode: &str, + active_task_ids: &[String], + carried_task_ids: &[String], + dependency_waves: &[Vec], + issues: &[String], + repair_routes: &[GameCreationAgentRepairRouteTrace], +) -> String { + let mut output = format!( + "# Orchestrator Agenda\n\n- pass: {pass}\n- mode: {}\n- activeTasks: {}\n- carriedTasks: {}\n\n", + mode, + active_task_ids.join(", "), + if carried_task_ids.is_empty() { + "none".to_string() + } else { + carried_task_ids.join(", ") + } + ); + output.push_str("## Repair Focus\n\n"); + if issues.is_empty() { + output.push_str("- 首轮生成,所有组内角色参与。\n"); + } else { + for issue in issues { + output.push_str("- "); + output.push_str(issue); + output.push('\n'); + } + } + output.push_str("\n## Dependency Waves\n\n"); + for (index, wave) in dependency_waves.iter().enumerate() { + output.push_str(&format!( + "- wave {}: {}\n", + index + 1, + if wave.is_empty() { + "none".to_string() + } else { + wave.join(", ") + } + )); + } + output.push_str("\n## Repair Routes\n\n"); + if repair_routes.is_empty() { + output.push_str("- none\n"); + } else { + for route in repair_routes { + output.push_str(&format!( + "- issue: {}\n taskIds: {}\n reason: {}\n", + route.issue, + route.task_ids.join(", "), + route.reason + )); + } + } + output.push_str( + "\n## Rule\n\n- activeTasks 产出对应角色 brief。\n- carriedTasks 沿用上一轮 brief,避免无关角色重复返工。\n- dependencyWaves 是按任务依赖排序后的执行层级,Generator 必须优先服从较早 wave 的约束。\n", + ); + output +} + +pub(crate) fn render_agent_pass_task_graph_json( + pass: u8, + mode: &str, + summary: &str, + active_task_ids: &[String], + carried_task_ids: &[String], + dependency_waves: &[Vec], + issues: &[String], + repair_routes: &[GameCreationAgentRepairRouteTrace], +) -> Result { + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": "game-creator-agent-pass-task-graph.v1", + "pass": pass, + "mode": mode, + "summary": summary, + "activeTaskIds": active_task_ids, + "carriedTaskIds": carried_task_ids, + "repairFocus": issues, + "repairRoutes": repair_routes, + "dependencyWaves": dependency_waves, + })) + .map(|payload| format!("{payload}\n")) + .map_err(|error| format!("生成 Agent pass task graph 失败:{error}")) +} + +pub(crate) fn read_previous_agent_role_brief( + root: &Path, + pass: u8, + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> Result, String> { + if pass <= 1 { + return Ok(None); + } + let relative_path = format!( + ".agent/passes/pass-{}/groups/{}/{}", + pass - 1, + group_definition.id, + role_definition.brief_path_name + ); + match fs::read_to_string(root.join(&relative_path)) { + Ok(content) => Ok(Some((relative_path, content))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "读取上一轮角色 brief 失败:{relative_path}: {error}" + )), + } +} + +pub(crate) fn render_carryover_role_brief( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + source_path: &str, + source_markdown: &str, +) -> String { + format!( + "本角色判断:沿用上一轮 {} / {} 输出。\n交付物:{}\n下游约束:本轮未命中该任务,Generator 只在必要时读取此约束。\n验收风险:如果 Evaluator 后续命中该组,下一轮必须重新激活。\n\n## 上一轮 brief\n\n{}", + group_definition.label, + role_definition.role, + source_path, + source_markdown.trim() + ) +} + +pub(crate) fn write_agent_pass_artifacts( + root: &Path, + pass: u8, + draft: &LlmGameDraft, +) -> Result { + let relative_dir = format!(".agent/passes/pass-{pass}"); + let pass_dir = root.join(&relative_dir); + fs::create_dir_all(&pass_dir) + .map_err(|error| format!("创建 Agent pass 目录失败:{}: {error}", pass_dir.display()))?; + + let paths = AgentPassArtifactPaths { + draft_json: format!("{relative_dir}/draft.json"), + design_markdown: format!("{relative_dir}/design.md"), + balance_json: format!("{relative_dir}/balance.json"), + art_manifest_json: format!("{relative_dir}/manifest.art.json"), + audio_manifest_json: format!("{relative_dir}/manifest.audio.json"), + publish_readme: format!("{relative_dir}/README.md"), + game_html: format!("{relative_dir}/game.html"), + handoff_markdown: format!("{relative_dir}/handoff.md"), + }; + + write_agent_pass_file( + root, + &paths.draft_json, + &format!( + "{}\n", + serde_json::to_string_pretty(draft) + .map_err(|error| format!("序列化 Agent pass draft 失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.design_markdown, + &format!("# 策划组 / Gameplay\n\n{}\n", draft.design_markdown.trim()), + )?; + write_agent_pass_file( + root, + &paths.balance_json, + &format!( + "{}\n", + serde_json::to_string_pretty(&draft.balance) + .map_err(|error| format!("序列化 Agent pass 数值失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.art_manifest_json, + &format!( + "{}\n", + serde_json::to_string_pretty(&draft.art_manifest) + .map_err(|error| format!("序列化 Agent pass 美术清单失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.audio_manifest_json, + &format!( + "{}\n", + serde_json::to_string_pretty(&draft.audio_manifest) + .map_err(|error| format!("序列化 Agent pass 音乐清单失败:{error}"))? + ), + )?; + write_agent_pass_file( + root, + &paths.publish_readme, + &format!("# 运营组 / Publish\n\n{}\n", draft.publish_readme.trim()), + )?; + write_agent_pass_file(root, &paths.game_html, &draft.game_html)?; + write_agent_pass_file( + root, + &paths.handoff_markdown, + &render_agent_pass_handoff(pass, draft), + )?; + + Ok(paths) +} + +pub(crate) fn write_agent_pass_file( + root: &Path, + relative_path: &str, + content: &str, +) -> Result<(), String> { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent pass 文件目录失败:{}: {error}", + parent.display() + ) + })?; + } + fs::write(&path, content) + .map_err(|error| format!("写入 Agent pass 文件失败:{}: {error}", path.display())) +} + +pub(crate) fn write_agent_group_brief( + root: &Path, + pass: u8, + definition: AgentGroupDefinition, + markdown: &str, +) -> Result { + let relative_path = format!( + ".agent/passes/pass-{pass}/groups/{}", + definition.brief_path_name + ); + write_agent_pass_file( + root, + &relative_path, + &format!( + "# {} / {}\n\n{}\n", + definition.label, + definition.role, + markdown.trim() + ), + )?; + Ok(relative_path) +} + +pub(crate) fn write_agent_role_brief( + root: &Path, + pass: u8, + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + markdown: &str, +) -> Result { + let relative_path = format!( + ".agent/passes/pass-{pass}/groups/{}/{}", + group_definition.id, role_definition.brief_path_name + ); + write_agent_pass_file( + root, + &relative_path, + &format!( + "# {} / {}\n\n- task: {}\n\n{}\n", + group_definition.label, + role_definition.role, + role_definition.task_id, + markdown.trim() + ), + )?; + Ok(relative_path) +} + +pub(crate) fn agent_role_memory_relative_path( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> String { + format!( + "memory/agents/{}/{}", + group_definition.id, role_definition.brief_path_name + ) +} + +pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result { + if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string()); + } + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + if role.task_id == task_id { + return Ok(agent_role_memory_relative_path(group, *role)); + } + } + } + Err(format!("未知 Agent 任务:{task_id}")) +} + +pub(crate) fn append_agent_success_memories( + root: &Path, + pass: u8, + draft: &LlmGameDraft, + briefs: &[AgentGroupBrief], +) -> Result<(), String> { + let timestamp = unix_timestamp(); + let title = draft.title.trim(); + let mut blackboard_entry = format!( + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n\n## 角色共享摘要\n\n" + ); + + for brief in briefs { + for role_brief in &brief.role_briefs { + blackboard_entry.push_str(&format!( + "- {} / {}:{};status={};brief={}\n", + role_brief.group_definition.label, + role_brief.role_definition.role, + role_brief.summary, + role_brief.status, + role_brief.relative_path + )); + let private_entry = format!( + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- task:{}\n- status:{}\n- brief:{}\n- 摘要:{}\n", + role_brief.role_definition.task_id, + role_brief.status, + role_brief.relative_path, + role_brief.summary + ); + append_markdown_entry( + &root.join(&role_brief.memory_relative_path), + &format!( + "# Agent 私有记忆 - {} / {}\n\n", + role_brief.group_definition.label, role_brief.role_definition.role + ), + &private_entry, + "写入 Agent 私有记忆失败", + )?; + } + } + + append_markdown_entry( + &root.join(PROJECT_BLACKBOARD_MEMORY_PATH), + "# 项目黑板\n\n", + &blackboard_entry, + "写入项目黑板失败", + ) +} + +pub(crate) fn append_group_brief_steps( + root: &Path, + pass: u8, + agenda_relative_path: &str, + briefs: &[AgentGroupBrief], + steps: &mut Vec, +) { + let canvas_asset_media_types = project_canvas_asset_media_types(root); + for brief in briefs { + for role_brief in &brief.role_briefs { + let input_paths = vec![ + "memory/session.md".to_string(), + "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/conversations/agents/".to_string(), + role_brief.memory_relative_path.clone(), + ".agent/manifest.json".to_string(), + ".agent/spec.md".to_string(), + ".agent/findings.md".to_string(), + agenda_relative_path.to_string(), + ]; + let mut output_paths = vec![role_brief.relative_path.clone()]; + if role_brief.status == "completed" || role_brief.status == "carried-over" { + output_paths.push(role_brief.memory_relative_path.clone()); + output_paths.push(PROJECT_BLACKBOARD_MEMORY_PATH.to_string()); + } + let mut step = with_task_context( + agent_trace_step_owned( + pass, + &format!( + "{} / {}", + role_brief.group_definition.label, role_brief.role_definition.role + ), + &role_brief.status, + input_paths.clone(), + output_paths, + &role_brief.summary, + &role_brief.tool_id, + ), + role_brief.group_definition.id, + role_brief.role_definition.role, + Some(role_brief.role_definition.task_id), + "role-brief", + ); + if let Some(tool_call) = + suggested_canvas_tool_call(role_brief, &input_paths, &canvas_asset_media_types) + { + step.tool_calls.push(tool_call); + } + steps.push(step); + } + let role_paths = brief + .role_briefs + .iter() + .map(|role_brief| role_brief.relative_path.clone()) + .collect::>(); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + &format!("{} / GroupCoordinator", brief.definition.label), + "completed", + role_paths, + vec![brief.relative_path.clone()], + "汇总组内角色 brief,交给 Generator", + &format!("agent.group.aggregate.{}", brief.definition.id), + ), + brief.definition.id, + "GroupCoordinator", + None, + "group-aggregate", + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs new file mode 100644 index 000000000..3d471a55b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -0,0 +1,413 @@ +use super::*; + +pub(crate) fn game_creator_system_prompt() -> &'static str { + r#"你是 Genarrative 的 AI 游戏创作 Generator。你必须读取 Planner 规格、六个专业组 agent brief(这些 brief 已由组内 Director / Gameplay / Asset / Code / Preview / Playtest / Polish / Publish 等角色分别产出并汇总)和 Evaluator findings,把它们整合为一个本地可运行 Web 游戏原型。只返回 JSON,不返回 Markdown 解释。 + +JSON schema: +{ + "title": "游戏标题", + "designMarkdown": "策划组输出,包含核心循环、输入、胜负条件、关卡目标", + "balance": { "playerSpeed": 180, "playerLives": 3, "difficultyRamp": "..." }, + "artManifest": { "source": "llm", "items": [ { "kind": "character|scene|ui|animation", "title": "...", "status": "needs-canvas|generated" } ] }, + "audioManifest": { "source": "llm", "items": [ { "kind": "background-music|sound-effect", "title": "...", "status": "needs-canvas|generated" } ] }, + "publishReadme": "运营组输出,包含标题、简介、标签、封面需求和下一步验收", + "handoffs": [ + { "group": "design", "role": "Gameplay", "summary": "策划交接摘要", "outputs": ["game/game_design.md"], "next": "交给数值、美术、音乐、程序组" }, + { "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" }, + { "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" }, + { "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" }, + { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" }, + { "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" } + ], + "handoffSummary": "六组 agent 的交接摘要,每组一行", + "gameHtml": "完整自包含 HTML,可直接保存为 game/index.html" +} + +gameHtml 规则: +- 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 +- 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 +- JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。 +- 玩法、文本、数值和视觉主题必须明显响应用户需求、Planner 规格和组内角色 brief,不要输出固定星核传送门模板。 +"# +} + +pub(crate) fn game_creator_planner_system_prompt() -> &'static str { + r#"你是 Genarrative 的 AI 游戏创作 Planner。输出一份给 Generator 使用的 Markdown 规格,不要生成代码。规格必须包含:核心循环、输入方式、胜负条件、首版关卡、6 个专业组分工、组内角色任务矩阵、Evaluator 验收标准。不要写客套说明。"# +} + +pub(crate) fn game_creator_planner_user_prompt( + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, +) -> String { + format!( + "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\n请输出 Planner 规格 Markdown。", + prompt.trim(), + truncate_prompt_context(short_memory), + truncate_prompt_context(long_memory), + truncate_prompt_context(project_blackboard) + ) +} + +pub(crate) fn game_creator_generator_user_prompt( + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + spec_markdown: &str, + findings_markdown: &str, + group_briefs_markdown: &str, + agenda_markdown: &str, +) -> String { + format!( + "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\nPlanner 规格文件 .agent/spec.md:\n{}\n\n本轮 Orchestrator agenda:\n{}\n\n六个专业组 agent brief(每组由组内角色汇总而成):\n{}\n\nEvaluator 反馈文件 .agent/findings.md:\n{}\n\n请直接返回满足 schema 的 JSON。如果 findings 有问题,必须优先修复 agenda 中 activeTasks 对应的任务。", + prompt.trim(), + truncate_prompt_context(short_memory), + truncate_prompt_context(long_memory), + truncate_prompt_context(project_blackboard), + truncate_prompt_context(spec_markdown), + truncate_prompt_context(agenda_markdown), + truncate_prompt_context(group_briefs_markdown), + truncate_prompt_context(findings_markdown) + ) +} + +pub(crate) fn append_prompt_context(base: &str, extra: &str) -> String { + match (base.trim().is_empty(), extra.trim().is_empty()) { + (_, true) => base.to_string(), + (true, false) => extra.trim().to_string(), + (false, false) => format!("{}\n\n{}", base.trim_end(), extra.trim()), + } +} + +pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result { + let manifest = read_manifest_for_project(root)?; + if manifest.assets.is_empty() { + return Ok(String::new()); + } + + let mut output = "# 本地项目资产\n\n".to_string(); + for asset in manifest.assets.iter().take(24) { + output.push_str("- "); + output.push_str(&asset.id); + output.push_str(": "); + output.push_str(&asset.kind); + output.push_str(" / "); + output.push_str(&asset.media_type); + output.push_str(" / "); + output.push_str(&asset.local_path); + output.push_str(" / source="); + output.push_str(asset_source_kind_label(&asset.source.kind)); + if let Some(canvas_project_id) = asset.source.canvas_project_id.as_deref() { + output.push_str(" / canvasProjectId="); + output.push_str(canvas_project_id); + } + if let Some(resource_id) = asset.source.resource_id.as_deref() { + output.push_str(" / resourceId="); + output.push_str(resource_id); + } + if let Some(asset_object_id) = asset.source.asset_object_id.as_deref() { + output.push_str(" / assetObjectId="); + output.push_str(asset_object_id); + } + if let Some(task_id) = asset.source.task_id.as_deref() { + output.push_str(" / taskId="); + output.push_str(task_id); + } + if let Some(model) = asset.source.model.as_deref() { + output.push_str(" / model="); + output.push_str(model); + } + output.push('\n'); + } + if manifest.assets.len() > 24 { + output.push_str(&format!( + "- ... 还有 {} 个资产\n", + manifest.assets.len() - 24 + )); + } + Ok(output) +} + +pub(crate) fn render_local_conversation_prompt_context( + root: &Path, + agent_id: Option<&str>, +) -> Result { + render_local_conversation_prompt_context_for_session(root, agent_id, None) +} + +pub(crate) fn render_local_conversation_prompt_context_for_session( + root: &Path, + agent_id: Option<&str>, + session_id: Option<&str>, +) -> Result { + #[derive(Debug)] + struct ConversationPromptEntry { + updated_at: u64, + agent_label: String, + role: String, + content: String, + } + + fn push_conversation_entries( + entries: &mut Vec, + conversation: LocalConversationResult, + agent_label: &str, + ) { + for message in conversation.messages { + let content = sanitize_prompt_context(&message.content) + .split_whitespace() + .collect::>() + .join(" "); + if content.is_empty() { + continue; + } + entries.push(ConversationPromptEntry { + updated_at: message.updated_at, + agent_label: agent_label.to_string(), + role: message.role, + content, + }); + } + } + + validate_project_root(root)?; + let mut entries = Vec::new(); + push_conversation_entries( + &mut entries, + read_local_conversation_for_session_at(root, None, None)?, + "project", + ); + + if let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) { + if agent_id == "*" { + if session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err("读取全部 Agent 对话时不能指定单个 sessionId".to_string()); + } + let agents_dir = root.join(".agent/conversations/agents"); + match fs::read_dir(&agents_dir) { + Ok(read_dir) => { + let mut agent_ids = std::collections::BTreeSet::new(); + for entry in read_dir { + let entry = entry.map_err(|error| { + format!("读取 Agent 对话目录失败:{}: {error}", agents_dir.display()) + })?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) == Some("jsonl") { + if let Some(agent_id) = + path.file_stem().and_then(|value| value.to_str()) + { + agent_ids.insert(agent_id.to_string()); + } + } else if path.is_dir() { + if let Some(agent_id) = + path.file_name().and_then(|value| value.to_str()) + { + agent_ids.insert(agent_id.to_string()); + } + } + } + for agent_id in agent_ids { + push_conversation_entries( + &mut entries, + read_local_conversation_for_session_at(root, Some(&agent_id), None)?, + &agent_id, + ); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent 对话目录失败:{}: {error}", + agents_dir.display() + )); + } + } + } else { + push_conversation_entries( + &mut entries, + read_local_conversation_for_session_at(root, Some(agent_id), session_id)?, + agent_id, + ); + } + } else if session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + { + return Err("项目主对话不接受 sessionId".to_string()); + } + + if entries.is_empty() { + return Ok(String::new()); + } + entries.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.agent_label.cmp(&right.agent_label)) + .then_with(|| left.role.cmp(&right.role)) + }); + entries.truncate(GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES); + entries.sort_by(|left, right| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.agent_label.cmp(&right.agent_label)) + .then_with(|| left.role.cmp(&right.role)) + }); + + let mut output = "# 最近对话上下文\n\n".to_string(); + for entry in entries { + output.push_str(&format!( + "- [{} / {}] {}\n", + entry.agent_label, entry.role, entry.content + )); + } + Ok(output) +} + +pub(crate) fn asset_source_kind_label(kind: &GameCreationAppAssetSourceKind) -> &'static str { + match kind { + GameCreationAppAssetSourceKind::Uploaded => "uploaded", + GameCreationAppAssetSourceKind::Generated => "generated", + GameCreationAppAssetSourceKind::Canvas => "canvas", + } +} + +pub(crate) fn truncate_prompt_context(value: &str) -> String { + const MAX_CHARS: usize = 2400; + let sanitized = sanitize_prompt_context(value); + let trimmed = sanitized.trim(); + let mut output = trimmed.chars().take(MAX_CHARS).collect::(); + if trimmed.chars().count() > MAX_CHARS { + output.push_str("\n..."); + } + output +} + +pub(crate) fn truncate_prompt_context_preserving_tail(value: &str) -> String { + const MAX_CHARS: usize = 2400; + let sanitized = sanitize_prompt_context(value); + let trimmed = sanitized.trim(); + if trimmed.chars().count() <= MAX_CHARS { + return trimmed.to_string(); + } + let mut characters = trimmed.chars().rev().take(MAX_CHARS).collect::>(); + characters.reverse(); + format!( + "...\n{}", + characters.into_iter().collect::() + ) +} + +pub(crate) fn sanitize_prompt_context(value: &str) -> String { + let mut sanitized = Vec::new(); + let mut inside_private_key = false; + for line in value.lines() { + let lower = line.to_ascii_lowercase(); + if inside_private_key { + if lower.contains("-----end") && lower.contains("private key") { + inside_private_key = false; + } + continue; + } + if lower.contains("-----begin") && lower.contains("private key") { + sanitized.push("[redacted sensitive context]".to_string()); + inside_private_key = true; + continue; + } + if lower.contains(".env") + || lower.contains("game-creator.config") + || lower.contains("authorization:") + || lower.contains("cookie:") + || lower.contains("api_key") + || lower.contains("apikey") + || lower.contains("api key") + || lower.contains("x-api-key") + || lower.contains("x_api_key") + || lower.contains("client_secret") + || lower.contains("clientsecret") + || lower.contains("access_token") + || lower.contains("accesstoken") + || lower.contains("refresh_token") + || lower.contains("refreshtoken") + || lower.contains("password=") + || lower.contains("password:") + || lower.contains("\"password\"") + || lower.contains("--password") + || lower.contains("--api-key") + || lower.contains("--apikey") + || lower.contains("--token") + || lower.contains("--secret") + || lower.contains("secret=") + || lower.contains("token=") + || lower.contains("\"token\"") + || lower.contains("bearer ") + { + sanitized.push("[redacted sensitive context]".to_string()); + } else { + sanitized.push(redact_secret_tokens(line)); + } + } + sanitized.join("\n") +} + +pub(crate) fn redact_secret_tokens(line: &str) -> String { + let mut spans = [ + ("tnr_sk_", 8usize), + ("sk-", 8), + ("ghp_", 20), + ("gho_", 20), + ("ghu_", 20), + ("ghs_", 20), + ("ghr_", 20), + ("npm_", 20), + ("AKIA", 16), + ("ASIA", 16), + ("AIza", 20), + ("sk_live_", 16), + ("rk_live_", 16), + ("xoxb-", 16), + ("xoxp-", 16), + ("xoxa-", 16), + ("xoxr-", 16), + ] + .into_iter() + .flat_map(|(prefix, minimum_body_length)| { + line.match_indices(prefix).filter_map(move |(index, _)| { + agent_runtime_secret_token_end_with_minimum(line, index, prefix, minimum_body_length) + .map(|token_end| (index, token_end)) + }) + }) + .collect::>(); + spans.extend(line.match_indices("eyJ").filter_map(|(index, _)| { + agent_runtime_jwt_token_end(line, index).map(|token_end| (index, token_end)) + })); + if spans.is_empty() { + return line.to_string(); + } + spans.sort_unstable_by_key(|(start, end)| (*start, *end)); + let mut output = String::with_capacity(line.len()); + let mut cursor = 0usize; + for (start, end) in spans { + if start < cursor { + continue; + } + output.push_str(&line[cursor..start]); + output.push_str("[redacted-secret]"); + cursor = end; + } + output.push_str(&line[cursor..]); + output +} + +pub(crate) fn read_optional_text(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(content) => Ok(content), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(format!("读取上下文失败:{}: {error}", path.display())), + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs new file mode 100644 index 000000000..c15f1bee5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs @@ -0,0 +1,804 @@ +use super::*; + +pub(crate) async fn request_agent_group_briefs_with_client( + root: &Path, + app_config: &GameCreatorAppConfig, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + spec_markdown: &str, + findings_markdown: &str, + agenda: &AgentPassAgenda, + run_id: &str, + pass: u8, +) -> Result, String> { + let mut role_briefs_by_task = BTreeMap::::new(); + let mut completed_group_context = String::new(); + let mut completed_role_context = String::new(); + let agenda_markdown = read_optional_text(&root.join(&agenda.relative_path))?; + + let ordered_roles = ordered_game_creator_agent_roles(); + let mut waves = if agenda.dependency_waves.is_empty() { + vec![ordered_roles + .iter() + .map(|(_, role)| role.task_id.to_string()) + .collect::>()] + } else { + agenda.dependency_waves.clone() + }; + let known_wave_task_ids = waves + .iter() + .flat_map(|wave| wave.iter()) + .cloned() + .collect::>(); + let missing_task_ids = ordered_roles + .iter() + .map(|(_, role)| role.task_id.to_string()) + .filter(|task_id| !known_wave_task_ids.contains(task_id)) + .collect::>(); + if !missing_task_ids.is_empty() { + waves.push(missing_task_ids); + } + + for wave in waves { + let mut role_brief_jobs = tokio::task::JoinSet::new(); + for task_id in wave { + let Some((definition, role_definition)) = game_creator_agent_role_definition(&task_id) + else { + continue; + }; + let agent_memory_relative_path = + agent_role_memory_relative_path(*definition, *role_definition); + let should_run = agenda + .active_task_ids + .iter() + .any(|active_task_id| active_task_id == role_definition.task_id); + if !should_run { + if let Some((source_path, source_markdown)) = + read_previous_agent_role_brief(root, pass, *definition, *role_definition)? + { + let runtime = start_game_creator_agent_runtime_task_at( + root, + role_definition.task_id, + &format!( + "pass {pass} 沿用 {} / {} brief", + definition.label, role_definition.role + ), + run_id, + "generate-draft", + "沿用上一轮角色 brief", + vec![ + "读取上一轮角色 brief".to_string(), + "写入本轮 carry-over brief".to_string(), + "把 carry-over 状态同步到 Agent Runtime".to_string(), + ], + )?; + let markdown = render_carryover_role_brief( + *definition, + *role_definition, + &source_path, + &source_markdown, + ); + let relative_path = write_agent_role_brief( + root, + pass, + *definition, + *role_definition, + &markdown, + )?; + let role_brief = AgentRoleBrief { + group_definition: *definition, + role_definition: *role_definition, + markdown, + relative_path, + memory_relative_path: agent_memory_relative_path, + status: "carried-over".to_string(), + tool_id: format!( + "agent.task_graph.carryover.{}.{}", + definition.id, role_definition.id + ), + summary: format!( + "沿用上一轮 {} / {} brief,未命中本轮修复范围", + definition.label, role_definition.role + ), + }; + let _ = finish_game_creator_agent_runtime_turn_at( + root, + runtime, + &role_brief.summary, + )?; + completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); + role_briefs_by_task.insert(role_definition.task_id.to_string(), role_brief); + continue; + } + } + let root = root.to_path_buf(); + let app_config = app_config.clone(); + let prompt = prompt.to_string(); + let short_memory = short_memory.to_string(); + let long_memory = long_memory.to_string(); + let project_blackboard = project_blackboard.to_string(); + let spec_markdown = spec_markdown.to_string(); + let findings_markdown = findings_markdown.to_string(); + let agenda_markdown = agenda_markdown.clone(); + let completed_group_context = completed_group_context.clone(); + let completed_role_context = completed_role_context.clone(); + let run_id = run_id.to_string(); + role_brief_jobs.spawn(async move { + build_agent_role_brief_draft( + root, + app_config, + *definition, + *role_definition, + prompt, + short_memory, + long_memory, + project_blackboard, + spec_markdown, + findings_markdown, + agenda_markdown, + completed_group_context, + completed_role_context, + run_id, + pass, + ) + .await + }); + } + let mut role_brief_drafts = Vec::new(); + while let Some(result) = role_brief_jobs.join_next().await { + let draft = result.map_err(|error| format!("角色 Agent 并行任务失败:{error}"))??; + role_brief_drafts.push(draft); + } + role_brief_drafts.sort_by_key(|draft| { + ordered_roles + .iter() + .position(|(_, role)| role.task_id == draft.role_definition.task_id) + .unwrap_or(usize::MAX) + }); + for draft in role_brief_drafts { + let relative_path = write_agent_role_brief( + root, + pass, + draft.group_definition, + draft.role_definition, + &draft.markdown, + )?; + let role_brief = AgentRoleBrief { + group_definition: draft.group_definition, + role_definition: draft.role_definition, + markdown: draft.markdown, + relative_path, + memory_relative_path: draft.memory_relative_path, + status: draft.status, + tool_id: draft.tool_id, + summary: draft.summary, + }; + completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); + role_briefs_by_task.insert(role_brief.role_definition.task_id.to_string(), role_brief); + } + completed_group_context = render_completed_agent_group_context(&role_briefs_by_task); + } + + let mut briefs = Vec::new(); + for definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + let role_briefs = definition + .roles + .iter() + .filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned()) + .collect::>(); + let markdown = render_agent_group_brief_markdown(&role_briefs); + let relative_path = write_agent_group_brief(root, pass, definition, &markdown)?; + briefs.push(AgentGroupBrief { + definition, + markdown, + relative_path, + role_briefs, + }); + } + Ok(briefs) +} + +#[derive(Debug)] +struct AgentRoleBriefDraft { + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + markdown: String, + memory_relative_path: String, + status: String, + tool_id: String, + summary: String, +} + +fn ordered_game_creator_agent_roles( +) -> Vec<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> { + GAME_CREATOR_AGENT_GROUP_DEFINITIONS + .iter() + .flat_map(|group_definition| { + group_definition + .roles + .iter() + .map(move |role_definition| (group_definition, role_definition)) + }) + .collect() +} + +fn render_completed_agent_group_context( + role_briefs_by_task: &BTreeMap, +) -> String { + let mut output = String::new(); + for group_definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + let role_briefs = group_definition + .roles + .iter() + .filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned()) + .collect::>(); + if role_briefs.is_empty() { + continue; + } + output.push_str(&format!( + "## {} / {}\n\n{}\n\n", + group_definition.label, + group_definition.role, + render_agent_group_brief_markdown(&role_briefs).trim() + )); + } + output +} + +#[allow(clippy::too_many_arguments)] +async fn build_agent_role_brief_draft( + root: PathBuf, + app_config: GameCreatorAppConfig, + definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + prompt: String, + short_memory: String, + long_memory: String, + project_blackboard: String, + spec_markdown: String, + findings_markdown: String, + agenda_markdown: String, + completed_group_context: String, + completed_role_context: String, + run_id: String, + pass: u8, +) -> Result { + let runtime = start_game_creator_agent_runtime_task_at( + &root, + role_definition.task_id, + &format!( + "pass {pass} 生成 {} / {} brief", + definition.label, role_definition.role + ), + &run_id, + "generate-draft", + "读取角色上下文", + vec![ + "读取项目记忆、黑板、Agent 私有记忆和最近对话".to_string(), + "根据 Orchestrator agenda 生成本角色 brief".to_string(), + "把角色 brief 写入本轮 pass 产物并同步 runtime 状态".to_string(), + ], + )?; + let result: Result = async { + let agent_memory_relative_path = + agent_role_memory_relative_path(definition, role_definition); + let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; + let agent_conversation_context = + render_local_conversation_prompt_context(&root, Some(role_definition.task_id))?; + let role_short_memory = append_prompt_context(&agent_conversation_context, &short_memory); + let local_markdown = render_local_agent_role_brief( + definition, + role_definition, + &prompt, + &role_short_memory, + &long_memory, + &project_blackboard, + &agent_memory, + &spec_markdown, + &findings_markdown, + &agenda_markdown, + &completed_group_context, + &completed_role_context, + pass, + ); + let runtime = advance_game_creator_agent_runtime_turn_at( + &root, + runtime, + "brief", + if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { + "调用角色专属 LLM 生成 brief" + } else { + "使用本地编排生成 brief" + }, + "角色上下文已读取。", + )?; + let (markdown, tool_id, summary) = + if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { + let markdown = request_agent_role_brief_with_config( + &app_config, + role_definition.task_id, + &local_markdown, + ) + .await?; + ( + markdown, + format!("llm.chat.{}", role_definition.task_id), + format!( + "{} / {} 使用 agentLlm.{} 生成 brief", + definition.label, role_definition.role, role_definition.task_id + ), + ) + } else { + ( + local_markdown, + role_definition.tool_id.to_string(), + format!( + "本地编排生成 {} / {} brief", + definition.label, role_definition.role + ), + ) + }; + let draft = AgentRoleBriefDraft { + group_definition: definition, + role_definition, + markdown, + memory_relative_path: agent_memory_relative_path, + status: "completed".to_string(), + tool_id, + summary, + }; + finish_game_creator_agent_runtime_turn_at(&root, runtime, &draft.summary)?; + Ok::(draft) + } + .await; + if let Err(error) = &result { + let failed_state = read_game_creator_agent_runtime_at(&root, role_definition.task_id) + .map(|result| result.state); + if let Ok(state) = failed_state { + let _ = fail_game_creator_agent_runtime_turn_at(&root, state, error); + } + } + result +} + +pub(crate) fn has_game_creator_agent_llm_override( + config: &GameCreatorAppConfig, + agent_id: &str, +) -> bool { + config + .agent_llm + .get(agent_id) + .is_some_and(|patch| !is_empty_game_creator_llm_patch(patch)) +} + +pub(crate) async fn request_agent_role_brief_with_config( + config: &GameCreatorAppConfig, + agent_id: &str, + local_markdown: &str, +) -> Result { + let llm = resolve_game_creator_llm_config_for_agent(config, agent_id); + let config_path = format!("agentLlm.{agent_id}"); + let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let request = apply_game_creator_llm_reasoning_effort( + LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_system_prompt()), + LlmMessage::user(format!( + "请基于下面的本地上下文生成本角色的 Markdown brief。只返回 brief 正文,不要代码块。\n\n{}", + truncate_prompt_context(local_markdown) + )), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS), + &llm, + )?; + let response = request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| format!("{config_path} 生成角色 brief 失败:{error}"))?; + let brief = strip_llm_thinking_blocks(response.text.as_str()); + if brief.is_empty() { + Err(format!("{config_path} 未返回角色 brief")) + } else { + Ok(brief) + } +} + +pub(crate) fn game_creator_role_agent_system_prompt() -> &'static str { + "你是 AI 游戏创作多智能体中的一个专业角色 agent。输出必须是简洁、可执行的 Markdown brief,服务于后续 Generator 生成可试玩 Web 小游戏原型。不要泄露密钥,不要输出 JSON,不要包裹代码块。" +} + +pub(crate) fn render_local_agent_role_brief( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, + agent_memory: &str, + spec_markdown: &str, + findings_markdown: &str, + agenda_markdown: &str, + completed_group_context: &str, + completed_role_context: &str, + pass: u8, +) -> String { + format!( + "本角色判断:pass {pass},{} / {} 负责 {},围绕用户需求“{}”给 Generator 提供约束。\n交付物:{}\n下游约束:{}\n验收风险:{}\n\n## Planner 摘要\n\n{}\n\n## Evaluator 反馈\n\n{}\n\n## 本轮 agenda\n\n{}\n\n## 记忆摘要\n\n短期:{}\n长期:{}\n项目黑板:{}\n本角色私有记忆:{}\n\n## 已完成上下文\n\n{}\n{}", + group_definition.label, + role_definition.role, + role_definition.task_id, + truncate_inline(prompt, 80), + role_definition.brief_path_name, + local_role_downstream_constraint(group_definition, role_definition), + local_role_acceptance_risk(group_definition, role_definition), + truncate_prompt_context(spec_markdown), + truncate_prompt_context(findings_markdown), + truncate_prompt_context(agenda_markdown), + truncate_inline(short_memory, 360), + truncate_inline(long_memory, 120), + truncate_inline(project_blackboard, 160), + truncate_inline(agent_memory, 160), + truncate_prompt_context(completed_group_context), + truncate_prompt_context(completed_role_context) + ) +} + +pub(crate) fn local_role_downstream_constraint( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> &'static str { + match (group_definition.id, role_definition.id) { + ("design", _) => "Generator 必须保留核心循环、输入、目标、失败、胜利和重开路径。", + ("balance", _) => "Generator 必须输出可被 game/balance.json 表达的速度、生命、难度和节奏参数。", + ("art", "asset") => "Generator 必须保留画板资源占位引用,并给出本地 fallback 视觉。", + ("art", _) => "Generator 必须保持像素风厨房主题和首版可读性。", + ("audio", _) => "Generator 必须给出 BGM 与核心交互音效清单,缺素材时使用静音 fallback。", + ("code", _) => "Generator 必须生成单文件 canvas HTML、输入监听、requestAnimationFrame 主循环和静态自检可通过的代码。", + ("publishing", _) => "Generator 必须输出标题、简介、标签、封面需求和试玩验收说明。", + _ => "Generator 必须响应本角色交付物并保持可试玩原型闭环。", + } +} + +pub(crate) fn local_role_acceptance_risk( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> &'static str { + match (group_definition.id, role_definition.id) { + ("code", _) => { + "缺 canvas 绘制、输入监听、胜负状态、重开或使用远程资源都会触发 Evaluator 返工。" + } + ("art", "asset") | ("audio", "sfx") => "未记录画板或本地资产占位会影响资产回流验收。", + ("publishing", _) => "缺发布包装会影响最终 exports/README.md 与运营组 handoff。", + _ => "输出空泛或偏离用户需求会增加 Generator 返工概率。", + } +} + +pub(crate) fn truncate_inline(value: &str, max_chars: usize) -> String { + let trimmed = value.split_whitespace().collect::>().join(" "); + let mut output = trimmed.chars().take(max_chars).collect::(); + if trimmed.chars().count() > max_chars { + output.push_str("..."); + } + output +} + +pub(crate) fn render_agent_group_brief_markdown(role_briefs: &[AgentRoleBrief]) -> String { + let mut output = String::new(); + for role_brief in role_briefs { + output.push_str(&format!( + "## {} / {}\n\n- task: {}\n- artifact: {}\n\n{}\n\n", + role_brief.group_definition.label, + role_brief.role_definition.role, + role_brief.role_definition.task_id, + role_brief.relative_path, + role_brief.markdown.trim() + )); + } + output +} + +pub(crate) fn render_agent_role_brief_context(role_brief: &AgentRoleBrief) -> String { + format!( + "## {} / {}\n\n{}\n\n", + role_brief.group_definition.label, + role_brief.role_definition.role, + role_brief.markdown.trim() + ) +} + +pub(crate) fn render_agent_group_briefs_context(briefs: &[AgentGroupBrief]) -> String { + let mut output = String::new(); + for brief in briefs { + output.push_str(&format!( + "## {} / {}\n\n{}\n\n", + brief.definition.label, + brief.definition.role, + brief.markdown.trim() + )); + } + output +} + +pub(crate) fn render_agent_pass_handoff(pass: u8, draft: &LlmGameDraft) -> String { + let mut output = format!("# Agent Handoff\n\n- pass: {pass}\n\n"); + for handoff in &draft.handoffs { + output.push_str(&format!( + "## {} / {}\n\n{}\n\n- outputs: {}\n- next: {}\n\n", + handoff_group_label(&handoff.group), + handoff.role.trim(), + handoff.summary.trim(), + handoff + .outputs + .iter() + .map(|output| output.trim()) + .filter(|output| !output.is_empty()) + .collect::>() + .join(", "), + handoff.next.trim() + )); + } + output.push_str("## 总结\n\n"); + output.push_str(draft.handoff_summary.trim()); + output.push('\n'); + output +} + +pub(crate) fn append_collaboration_steps( + pass: u8, + draft: &LlmGameDraft, + paths: &AgentPassArtifactPaths, + steps: &mut Vec, +) { + let design = + handoff_summary_for_group(draft, "design", "拆出核心循环、胜负条件和第一版关卡目标"); + let balance = handoff_summary_for_group(draft, "balance", "沉淀速度、生命、得分和难度参数"); + let art = handoff_summary_for_group(draft, "art", "整理角色、场景、UI 和动画资产需求"); + let audio = handoff_summary_for_group(draft, "audio", "整理 BGM 和核心交互音效需求"); + let code = + handoff_summary_for_group(draft, "code", "生成可由本地 HTTP server 预览的 canvas 原型"); + let publishing = + handoff_summary_for_group(draft, "publishing", "整理标题、标签、说明和发布前检查"); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "策划组 / Gameplay", + "completed", + vec![".agent/spec.md".to_string(), paths.draft_json.clone()], + vec![ + paths.design_markdown.clone(), + paths.handoff_markdown.clone(), + ], + &design, + "agent.handoff.design", + ), + "design", + "Gameplay", + Some("design-foundation"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "数值组 / Difficulty", + "completed", + vec![paths.design_markdown.clone()], + vec![paths.balance_json.clone(), paths.handoff_markdown.clone()], + &balance, + "agent.handoff.balance", + ), + "balance", + "Difficulty", + Some("balance-seed"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "美术组 / Asset", + "completed", + vec![paths.design_markdown.clone()], + vec![ + paths.art_manifest_json.clone(), + paths.handoff_markdown.clone(), + ], + &art, + "agent.handoff.art", + ), + "art", + "Asset", + Some("art-asset-plan"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "音乐组 / SFX", + "completed", + vec![paths.design_markdown.clone()], + vec![ + paths.audio_manifest_json.clone(), + paths.handoff_markdown.clone(), + ], + &audio, + "agent.handoff.audio", + ), + "audio", + "SFX", + Some("audio-asset-plan"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "程序组 / Code", + "completed", + vec![ + paths.design_markdown.clone(), + paths.balance_json.clone(), + paths.art_manifest_json.clone(), + paths.audio_manifest_json.clone(), + ], + vec![paths.game_html.clone(), paths.handoff_markdown.clone()], + &code, + "agent.handoff.code", + ), + "code", + "Code", + Some("code-prototype"), + "handoff", + )); + steps.push(with_task_context( + agent_trace_step_owned( + pass, + "运营组 / Publish", + "completed", + vec![paths.design_markdown.clone(), paths.game_html.clone()], + vec![paths.publish_readme.clone(), paths.handoff_markdown.clone()], + &publishing, + "agent.handoff.publish", + ), + "publishing", + "Publish", + Some("publish-package"), + "handoff", + )); +} + +pub(crate) fn handoff_summary_for_group( + draft: &LlmGameDraft, + group: &str, + fallback: &str, +) -> String { + draft + .handoffs + .iter() + .find(|handoff| handoff.group.trim() == group) + .map(|handoff| handoff.summary.trim()) + .filter(|summary| !summary.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +pub(crate) fn handoff_group_label(group: &str) -> &'static str { + match group.trim() { + "design" => "策划组", + "balance" => "数值组", + "art" => "美术组", + "audio" => "音乐组", + "code" => "程序组", + "publishing" => "运营组", + _ => "专业组", + } +} + +pub(crate) fn append_agent_loop_log( + root: &Path, + loop_result: &GameCreatorAgentLoopResult, +) -> Result<(), String> { + let agent_log_path = root.join(".agent/logs/agent.log"); + let timestamp = unix_timestamp(); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&agent_log_path) + .and_then(|mut file| { + file.write_all( + format!( + "{timestamp} agent.loop passes={}\nPlanner -> .agent/spec.md\n组内角色 briefs -> .agent/passes/pass-*/groups//*.md\n专业组汇总 -> .agent/passes/pass-*/groups/*.md\nGenerator -> .agent/passes/pass-*/draft.json\n专业组 handoffs -> .agent/passes/pass-*/handoff.md\nEvaluator -> .agent/findings.md\n{}\n{}\n", + loop_result.passes, + loop_result.spec_markdown.trim(), + loop_result.findings_markdown.trim() + ) + .as_bytes(), + ) + }) + .map_err(|error| format!("写入 Agent loop 日志失败:{}: {error}", agent_log_path.display()))?; + append_agent_loop_memory(root, loop_result) +} + +pub(crate) fn append_agent_loop_memory( + root: &Path, + loop_result: &GameCreatorAgentLoopResult, +) -> Result<(), String> { + let trace_path = root.join(".agent/run.latest.json"); + let trace_content = fs::read_to_string(&trace_path).map_err(|error| { + format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + let trace = + serde_json::from_str::(&trace_content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + let timestamp = unix_timestamp(); + let final_artifacts = [ + "game/index.html", + "game/game_design.md", + "game/balance.json", + "assets/manifest.art.json", + "assets/manifest.audio.json", + "exports/README.md", + ] + .into_iter() + .filter(|path| { + trace + .artifacts + .iter() + .any(|artifact| artifact.path == *path) + }) + .collect::>() + .join(", "); + let active_tasks = join_or_none(&trace.task_graph.active_task_ids); + let carried_tasks = join_or_none(&trace.task_graph.carried_task_ids); + let short_entry = format!( + "\n## Agent Run {}\n\n- 时间:{}\n- 标题:{}\n- 状态:{};loop:{}/{};下一步:{}\n- activeTasks:{}\n- carryOverTasks:{}\n- 产物:{}\n", + loop_result.run_id, + timestamp, + loop_result.draft.title.trim(), + trace.status, + trace.passes, + trace.max_passes, + trace.next_step, + active_tasks, + carried_tasks, + if final_artifacts.is_empty() { + "none" + } else { + final_artifacts.as_str() + } + ); + append_markdown_entry( + &root.join("memory/session.md"), + "# 短期记忆\n\n", + &short_entry, + "写入短期 Agent 记忆失败", + )?; + + let long_entry = format!( + "\n## 最近稳定原型\n\n- 时间:{}\n- 标题:{}\n- runId:{}\n- 通过轮次:{}/{}\n- 可试玩入口:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n", + timestamp, + loop_result.draft.title.trim(), + loop_result.run_id, + trace.passes, + trace.max_passes + ); + append_markdown_entry( + &root.join("memory/project.md"), + "# 项目长期记忆\n\n## 当前约束\n\n- Web 小游戏原型\n- 本地 HTTP 预览\n\n", + &long_entry, + "写入长期 Agent 记忆失败", + ) +} + +pub(crate) fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "none".to_string() + } else { + values.join(", ") + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs new file mode 100644 index 000000000..d70ede095 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs @@ -0,0 +1,393 @@ +use super::*; + +pub(crate) fn update_agent_run_lifecycle( + root: &Path, + action: &str, + detail: Option<&str>, +) -> Result { + let mut trace = read_latest_agent_run_trace(root)?; + let (status, lifecycle_status, next_step, event, message) = match action { + "status" => { + let lifecycle = trace + .lifecycle_status + .clone() + .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()); + ( + trace.status.clone(), + lifecycle.clone(), + trace.next_step.clone(), + "agent.run_status", + format!( + "run {} 当前状态:{} / {}", + trace.run_id, trace.status, lifecycle + ), + ) + } + "kill" => ( + "killed".to_string(), + "killed".to_string(), + "resume-or-retry".to_string(), + "agent.kill", + format!("run {} 已标记为 killed", trace.run_id), + ), + "retry" => ( + "pending".to_string(), + "pending".to_string(), + "rerun-now".to_string(), + "agent.retry", + format!("run {} 已请求重试", trace.run_id), + ), + "resume" => ( + "pending".to_string(), + "pending".to_string(), + "rerun-now".to_string(), + "agent.resume", + format!( + "run {} 已恢复:{}", + trace.run_id, + detail.unwrap_or("继续运行最近目标") + ), + ), + _ => return Err("未知 Agent run 控制动作".to_string()), + }; + + if action != "status" { + trace.status = status; + trace.lifecycle_status = Some(lifecycle_status); + trace.next_step = next_step; + trace.stop_reason = match action { + "kill" => "killed", + "retry" => "retry-requested", + "resume" => "human-resume", + _ => trace.stop_reason.as_str(), + } + .to_string(); + trace.error = if action == "kill" { + Some("用户请求停止当前 run".to_string()) + } else { + None + }; + trace.updated_at = unix_timestamp(); + write_agent_run_trace_payload(root, &trace)?; + } + + append_agent_run_activity(root, &trace.run_id, event, &message)?; + append_agent_run_output(root, &trace.run_id, event, &message)?; + write_agent_run_context_bundle(root, &trace)?; + + agent_run_control_result_from_trace(root, trace, message) +} + +pub(crate) async fn control_agent_run_at( + root: &Path, + action: &str, + detail: Option<&str>, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let previous_trace = read_latest_agent_run_trace(root)?; + let control_result = update_agent_run_lifecycle(root, action, detail)?; + if !matches!(action, "retry" | "resume") { + return Ok(control_result); + } + + let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail); + let generated = generate_local_game_draft_at(root, &prompt, progress).await?; + let trace = read_latest_agent_run_trace(root)?; + let message = format!( + "{},已重新运行为 {}:{}", + control_result.message, trace.run_id, generated.game_index_path + ); + let event = if action == "retry" { + "agent.retry.run" + } else { + "agent.resume.run" + }; + append_agent_run_activity(root, &trace.run_id, event, &message)?; + append_agent_run_output(root, &trace.run_id, event, &message)?; + write_agent_run_context_bundle(root, &trace)?; + agent_run_control_result_from_trace(root, trace, message) +} + +pub(crate) fn resumed_agent_run_prompt(goal: &str, action: &str, detail: Option<&str>) -> String { + let goal = goal.trim(); + let detail = detail.map(str::trim).filter(|value| !value.is_empty()); + match (action, detail) { + ("resume", Some(detail)) => format!("{goal}\n\n继续说明:{detail}"), + _ => goal.to_string(), + } +} + +pub(crate) fn read_latest_agent_run_trace( + root: &Path, +) -> Result { + let trace_path = root.join(".agent/run.latest.json"); + let content = fs::read_to_string(&trace_path).map_err(|error| { + format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + }) +} + +pub(crate) fn agent_run_control_result_from_trace( + root: &Path, + trace: GameCreationAgentRunTrace, + message: String, +) -> Result { + Ok(AgentRunControlResult { + run_id: trace.run_id, + lifecycle_status: trace + .lifecycle_status + .clone() + .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()), + status: trace.status, + next_step: trace.next_step, + message, + activity_path: root + .join(".agent/activity.jsonl") + .to_string_lossy() + .to_string(), + output_path: root + .join(".agent/output.jsonl") + .to_string_lossy() + .to_string(), + context_bundle_path: root + .join(".agent/context.bundle.json") + .to_string_lossy() + .to_string(), + }) +} + +pub(crate) fn count_agent_tool_calls(steps: &[GameCreationAgentRunStep]) -> Result { + let count = steps.iter().try_fold(0u16, |current, step| { + let step_count = u16::try_from(step.tool_calls.len()) + .map_err(|_| "Agent 工具调用数超过上限".to_string())?; + current + .checked_add(step_count) + .ok_or_else(|| "Agent 工具调用数超过上限".to_string()) + })?; + if count > GAME_CREATOR_AGENT_TOOL_CALL_MAX { + return Err(format!( + "Agent 工具调用预算超限:{count}/{GAME_CREATOR_AGENT_TOOL_CALL_MAX}" + )); + } + Ok(count) +} + +pub(crate) fn write_agent_run_trace_payload( + root: &Path, + trace: &GameCreationAgentRunTrace, +) -> Result<(), String> { + if trace.run_id.contains('/') || trace.run_id.contains('\\') || trace.run_id.contains("..") { + return Err("Agent run_id 非法".to_string()); + } + let payload = serde_json::to_string_pretty(&trace) + .map_err(|error| format!("生成 Agent run trace 失败:{error}"))?; + let latest_path = root.join(".agent/run.latest.json"); + fs::write(&latest_path, &payload).map_err(|error| { + format!( + "写入 Agent run trace 失败:{}: {error}", + latest_path.display() + ) + })?; + let run_dir = root.join(".agent/runs"); + fs::create_dir_all(&run_dir).map_err(|error| { + format!( + "创建 Agent run history 目录失败:{}: {error}", + run_dir.display() + ) + })?; + let run_path = run_dir.join(format!("{}.json", trace.run_id)); + fs::write(&run_path, payload).map_err(|error| { + format!( + "写入 Agent run history 失败:{}: {error}", + run_path.display() + ) + })?; + prune_agent_run_history(&run_dir) +} + +pub(crate) fn prune_agent_run_history(run_dir: &Path) -> Result<(), String> { + let entries = match fs::read_dir(run_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Agent run history 失败:{}: {error}", + run_dir.display() + )); + } + }; + let mut run_files = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "读取 Agent run history 失败:{}: {error}", + run_dir.display() + ) + })?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let updated_at = fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .map(|trace| trace.updated_at) + .unwrap_or(0); + run_files.push((updated_at, path)); + } + if run_files.len() <= GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT { + return Ok(()); + } + run_files.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); + for (_, path) in run_files + .into_iter() + .skip(GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT) + { + fs::remove_file(&path).map_err(|error| { + format!("删除旧 Agent run history 失败:{}: {error}", path.display()) + })?; + } + Ok(()) +} + +pub(crate) fn collect_agent_run_artifacts( + root: &Path, +) -> Result, String> { + let mut relative_paths = GAME_CREATOR_AGENT_ARTIFACT_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect::>(); + let passes_dir = root.join(".agent/passes"); + if passes_dir.exists() { + let mut pass_dirs = fs::read_dir(&passes_dir) + .map_err(|error| { + format!( + "读取 Agent pass 目录失败:{}: {error}", + passes_dir.display() + ) + })? + .collect::, _>>() + .map_err(|error| { + format!( + "读取 Agent pass 目录失败:{}: {error}", + passes_dir.display() + ) + })?; + pass_dirs.sort_by_key(|entry| entry.path()); + for pass_dir in pass_dirs { + let pass_path = pass_dir.path(); + let metadata = fs::symlink_metadata(&pass_path).map_err(|error| { + format!( + "读取 Agent pass 元数据失败:{}: {error}", + pass_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err("Agent pass 目录不能是符号链接".to_string()); + } + if !metadata.is_dir() { + continue; + } + let mut dirs = vec![pass_path]; + while let Some(dir) = dirs.pop() { + let mut entries = fs::read_dir(&dir) + .map_err(|error| { + format!("读取 Agent pass 文件失败:{}: {error}", dir.display()) + })? + .collect::, _>>() + .map_err(|error| { + format!("读取 Agent pass 文件失败:{}: {error}", dir.display()) + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let entry_path = entry.path(); + let metadata = fs::symlink_metadata(&entry_path).map_err(|error| { + format!( + "读取 Agent pass 文件元数据失败:{}: {error}", + entry_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err("Agent pass artifact 不能是符号链接".to_string()); + } + if metadata.is_dir() { + dirs.push(entry_path); + } else if metadata.is_file() { + relative_paths.push(relative_project_path(root, &entry_path)?); + } + } + } + } + } + let agent_memory_dir = root.join("memory/agents"); + if agent_memory_dir.exists() { + let mut dirs = vec![agent_memory_dir]; + while let Some(dir) = dirs.pop() { + let mut entries = fs::read_dir(&dir) + .map_err(|error| { + format!("读取 Agent 私有记忆目录失败:{}: {error}", dir.display()) + })? + .collect::, _>>() + .map_err(|error| { + format!("读取 Agent 私有记忆目录失败:{}: {error}", dir.display()) + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let entry_path = entry.path(); + let metadata = fs::symlink_metadata(&entry_path).map_err(|error| { + format!( + "读取 Agent 私有记忆元数据失败:{}: {error}", + entry_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err("Agent 私有记忆不能是符号链接".to_string()); + } + if metadata.is_dir() { + dirs.push(entry_path); + } else if metadata.is_file() { + relative_paths.push(relative_project_path(root, &entry_path)?); + } + } + } + } + + relative_paths.sort(); + relative_paths.dedup(); + let mut artifacts = Vec::new(); + for relative_path in relative_paths { + let path = root.join(&relative_path); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!( + "读取 Agent artifact 元数据失败:{}: {error}", + path.display() + )) + } + }; + if metadata.file_type().is_symlink() { + return Err("Agent artifact 不能是符号链接".to_string()); + } + if !metadata.is_file() { + continue; + } + let bytes = fs::read(&path) + .map_err(|error| format!("读取 Agent artifact 失败:{}: {error}", path.display()))?; + artifacts.push(GameCreationAgentArtifactTrace { + path: relative_path, + size_bytes: metadata.len(), + checksum: format!("fnv1a64:{:016x}", fnv1a64(&bytes)), + }); + } + Ok(artifacts) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs new file mode 100644 index 000000000..31381a004 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[cfg(test)] +pub(crate) async fn request_llm_game_draft_with_client( + client: &LlmClient, + prompt: &str, + short_memory: &str, + long_memory: &str, +) -> Result { + let llm = GameCreatorLlmConfig::default(); + request_generator_game_draft_with_client( + client, + &llm, + prompt, + short_memory, + long_memory, + "", + "", + "", + "", + "", + None, + ) + .await +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs new file mode 100644 index 000000000..4b172857c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs @@ -0,0 +1,834 @@ +use super::*; + +pub(crate) fn agent_trace_step( + pass: u8, + agent: &str, + status: &str, + input_paths: &[&str], + output_paths: &[&str], + summary: &str, + tool_id: &str, +) -> GameCreationAgentRunStep { + agent_trace_step_owned( + pass, + agent, + status, + input_paths + .iter() + .map(|path| (*path).to_string()) + .collect::>(), + output_paths + .iter() + .map(|path| (*path).to_string()) + .collect::>(), + summary, + tool_id, + ) +} + +pub(crate) fn agent_trace_step_owned( + pass: u8, + agent: &str, + status: &str, + input_paths: Vec, + output_paths: Vec, + summary: &str, + tool_id: &str, +) -> GameCreationAgentRunStep { + GameCreationAgentRunStep { + pass, + agent: agent.to_string(), + phase: infer_agent_trace_phase(tool_id).to_string(), + task_id: None, + group: None, + role: None, + status: status.to_string(), + input_paths: input_paths.clone(), + output_paths: output_paths.clone(), + summary: summary.to_string(), + tool_calls: vec![GameCreationAgentToolCallTrace { + tool_id: tool_id.to_string(), + status: status.to_string(), + input_paths, + output_paths, + summary: summary.to_string(), + }], + } +} + +pub(crate) fn infer_agent_trace_phase(tool_id: &str) -> &'static str { + if tool_id == "llm.chat.planner" { + "planning" + } else if tool_id.starts_with("agent.task_graph.") { + "orchestration" + } else if tool_id.starts_with("agent.role.brief.") { + "role-brief" + } else if tool_id.starts_with("agent.group.aggregate.") { + "group-aggregate" + } else if tool_id == "llm.chat.generator" { + "generation" + } else if tool_id.starts_with("agent.handoff.") { + "handoff" + } else if tool_id.starts_with("evaluator.") || tool_id == "file.write.findings" { + "evaluation" + } else if tool_id == "file.write.local_artifacts" { + "artifact-write" + } else if tool_id == "game.static_smoke" { + "playtest" + } else if tool_id.starts_with("preview.") { + "preview" + } else { + "tool" + } +} + +pub(crate) fn with_task_context( + mut step: GameCreationAgentRunStep, + group_id: &str, + role: &str, + task_id: Option<&str>, + phase: &str, +) -> GameCreationAgentRunStep { + step.phase = phase.to_string(); + step.group = game_creation_agent_group_from_id(group_id); + step.role = Some(role.to_string()); + step.task_id = task_id.map(str::to_string); + step +} + +pub(crate) fn game_creation_agent_group_from_id( + group_id: &str, +) -> Option { + match group_id { + "design" => Some(GameCreationAppAgentGroup::Design), + "balance" => Some(GameCreationAppAgentGroup::Balance), + "art" => Some(GameCreationAppAgentGroup::Art), + "audio" => Some(GameCreationAppAgentGroup::Audio), + "code" => Some(GameCreationAppAgentGroup::Code), + "publishing" => Some(GameCreationAppAgentGroup::Publishing), + _ => None, + } +} + +pub(in crate::agent) fn game_creation_agent_group_id( + group: &GameCreationAppAgentGroup, +) -> &'static str { + match group { + GameCreationAppAgentGroup::Design => "design", + GameCreationAppAgentGroup::Balance => "balance", + GameCreationAppAgentGroup::Art => "art", + GameCreationAppAgentGroup::Audio => "audio", + GameCreationAppAgentGroup::Code => "code", + GameCreationAppAgentGroup::Publishing => "publishing", + } +} + +pub(crate) fn append_static_smoke_step( + root: &Path, + prompt: &str, + loop_result: &mut GameCreatorAgentLoopResult, +) -> Result<(), String> { + match run_limited_local_command_at(root, "game.static_smoke") { + Ok(smoke) => { + loop_result.steps.push(with_task_context( + agent_trace_step( + loop_result.passes, + "Playtest", + "completed", + &["game/index.html"], + &[".agent/logs/command.log", ".agent/manifest.json"], + &smoke.output, + "game.static_smoke", + ), + "code", + "Preview", + Some("preview-readiness"), + "playtest", + )); + write_agent_run_trace( + root, + &loop_result.run_id, + prompt, + "passed", + loop_result.passes, + &loop_result.steps, + None, + ) + } + Err(error) => { + loop_result.steps.push(with_task_context( + agent_trace_step( + loop_result.passes, + "Playtest", + "failed", + &["game/index.html"], + &[".agent/logs/command.log"], + &error, + "game.static_smoke", + ), + "code", + "Preview", + Some("preview-readiness"), + "playtest", + )); + write_agent_run_trace( + root, + &loop_result.run_id, + prompt, + "failed", + loop_result.passes, + &loop_result.steps, + Some(&error), + )?; + Err(format!("生成后自检失败:{error}")) + } + } +} + +pub(crate) fn append_static_smoke_manual_trace_step( + root: &Path, + result: &LimitedLocalCommandResult, +) -> Result<(), String> { + append_agent_run_trace_step( + root, + "passed", + "preview-playtest", + with_task_context( + agent_trace_step( + 0, + "Playtest", + "completed", + &["game/index.html"], + &[".agent/logs/command.log", ".agent/manifest.json"], + &result.output, + "game.static_smoke", + ), + "code", + "Preview", + Some("preview-readiness"), + "playtest", + ), + None, + ) +} + +pub(crate) fn append_local_artifact_write_step( + root: &Path, + prompt: &str, + loop_result: &mut GameCreatorAgentLoopResult, +) -> Result<(), String> { + loop_result.steps.push(agent_trace_step_owned( + loop_result.passes, + "ArtifactWriter", + "completed", + vec![ + format!(".agent/passes/pass-{}/draft.json", loop_result.passes), + format!(".agent/passes/pass-{}/handoff.md", loop_result.passes), + ], + vec![ + "memory/session.md".to_string(), + "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + "memory/agents/".to_string(), + "game/game_design.md".to_string(), + "game/balance.json".to_string(), + "assets/manifest.art.json".to_string(), + "assets/manifest.audio.json".to_string(), + "exports/README.md".to_string(), + "game/index.html".to_string(), + ".agent/manifest.json".to_string(), + ], + "把通过 Evaluator 的草案写入本地项目产物", + "file.write.local_artifacts", + )); + write_agent_run_trace( + root, + &loop_result.run_id, + prompt, + "artifacts-written", + loop_result.passes, + &loop_result.steps, + None, + ) +} + +pub(crate) fn append_preview_start_trace_step( + root: &Path, + preview: &LocalPreviewResult, +) -> Result<(), String> { + append_agent_run_trace_step( + root, + "preview-running", + "manual-playtest", + with_task_context( + agent_trace_step( + 0, + "Preview", + "running", + &["game/index.html"], + &[".agent/manifest.json", ".agent/logs/preview.log"], + &format!("本地 HTTP 预览已启动:{}", preview.url), + "preview.start", + ), + "code", + "Playtest", + Some("preview-playtest"), + "preview", + ), + None, + ) +} + +pub(crate) fn append_preview_stop_trace_step(root: &Path) -> Result<(), String> { + append_agent_run_trace_step( + root, + "preview-stopped", + "inspect-artifacts", + with_task_context( + agent_trace_step( + 0, + "Preview", + "stopped", + &[".agent/manifest.json"], + &[".agent/manifest.json", ".agent/logs/preview.log"], + "本地 HTTP 预览已停止", + "preview.stop", + ), + "code", + "Playtest", + Some("preview-playtest"), + "preview", + ), + None, + ) +} + +pub(crate) fn append_preview_log( + root: &Path, + status: &str, + url: Option<&str>, +) -> Result<(), String> { + let log_path = root.join(".agent/logs/preview.log"); + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建预览日志目录失败:{}: {error}", parent.display()))?; + } + let line = match url { + Some(url) => format!("{} preview.{status} {url}\n", unix_timestamp()), + None => format!("{} preview.{status}\n", unix_timestamp()), + }; + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|mut file| file.write_all(line.as_bytes())) + .map_err(|error| format!("写入预览日志失败:{}: {error}", log_path.display())) +} + +pub(crate) fn record_replaced_preview_stop(preview: &LocalPreviewResult) { + let root = Path::new(&preview.root); + let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None); + let _ = append_preview_log(root, "stopped", None); + let _ = append_preview_stop_trace_step(root); +} + +pub(crate) fn append_agent_run_trace_step( + root: &Path, + status: &str, + next_step: &str, + step: GameCreationAgentRunStep, + error: Option<&str>, +) -> Result<(), String> { + let trace_path = root.join(".agent/run.latest.json"); + let content = match fs::read_to_string(&trace_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + )) + } + }; + let mut trace = + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + trace.steps.push(step); + trace.status = status.to_string(); + trace.next_step = next_step.to_string(); + trace.error = error.map(str::to_string); + trace.stop_reason = agent_run_stop_reason(status, error).to_string(); + trace.tool_call_count = count_agent_tool_calls(&trace.steps)?; + trace.max_tool_calls = GAME_CREATOR_AGENT_TOOL_CALL_MAX; + trace.artifacts = collect_agent_run_artifacts(root)?; + trace.task_graph = + build_agent_run_task_graph_trace(root, &trace.goal, trace.passes, &trace.steps)?; + trace.pass_plans = collect_agent_pass_plan_traces(root, trace.passes)?; + trace.updated_at = unix_timestamp(); + write_agent_run_trace_payload(root, &trace) +} + +#[derive(Default)] +pub(crate) struct AgentAgendaSnapshot { + active_task_ids: Vec, + carried_task_ids: Vec, + repair_focus: Vec, + repair_routes: Vec, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentPassTaskGraphSnapshot { + #[serde(default)] + pass: u8, + #[serde(default)] + mode: String, + #[serde(default)] + summary: String, + #[serde(default)] + active_task_ids: Vec, + #[serde(default)] + carried_task_ids: Vec, + #[serde(default)] + dependency_waves: Vec>, + #[serde(default)] + repair_focus: Vec, + #[serde(default)] + repair_routes: Vec, +} + +pub(crate) fn build_agent_run_task_graph_trace( + root: &Path, + goal: &str, + pass: u8, + steps: &[GameCreationAgentRunStep], +) -> Result { + let agenda = read_agent_agenda_snapshot(root, pass)?; + let mut tasks = new_game_creation_app_seed_tasks(); + + for task_id in &agenda.active_task_ids { + set_task_status_if_current(&mut tasks, task_id, GameCreationAppTaskStatus::Running); + } + for task_id in &agenda.carried_task_ids { + set_task_status_if_current(&mut tasks, task_id, GameCreationAppTaskStatus::Completed); + } + + for step in steps { + let Some(task_id) = step.task_id.as_deref() else { + continue; + }; + let Some(status) = task_status_from_agent_step(step, task_id) else { + continue; + }; + set_task_status_if_current(&mut tasks, task_id, status); + } + + if task_has_status( + &tasks, + "preview-readiness", + GameCreationAppTaskStatus::Completed, + ) && !steps.iter().any(|step| { + step.task_id.as_deref() == Some("preview-playtest") + && step.phase == "preview" + && step.status == "running" + }) { + set_task_status_if_current( + &mut tasks, + "preview-playtest", + GameCreationAppTaskStatus::WaitingForConfirmation, + ); + } + + Ok(GameCreationAgentRunTaskGraphTrace { + goal: goal.trim().to_string(), + ready_task_ids: ready_task_ids_for_tasks(&tasks), + active_task_ids: agenda.active_task_ids, + carried_task_ids: agenda.carried_task_ids, + repair_focus: agenda.repair_focus, + repair_routes: agenda.repair_routes, + tasks, + }) +} + +pub(crate) fn read_agent_agenda_snapshot( + root: &Path, + pass: u8, +) -> Result { + if pass == 0 { + return Ok(AgentAgendaSnapshot::default()); + } + let task_graph_relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); + match fs::read_to_string(root.join(&task_graph_relative_path)) { + Ok(content) => { + let snapshot: AgentPassTaskGraphSnapshot = + serde_json::from_str(&content).map_err(|error| { + format!("解析 Agent task graph 失败:{task_graph_relative_path}: {error}") + })?; + return Ok(AgentAgendaSnapshot { + active_task_ids: snapshot.active_task_ids, + carried_task_ids: snapshot.carried_task_ids, + repair_focus: snapshot.repair_focus, + repair_routes: snapshot.repair_routes, + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent task graph 失败:{task_graph_relative_path}: {error}" + )) + } + } + let relative_path = format!(".agent/passes/pass-{pass}/agenda.md"); + let agenda = match fs::read_to_string(root.join(&relative_path)) { + Ok(agenda) => agenda, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AgentAgendaSnapshot::default()) + } + Err(error) => return Err(format!("读取 Agent agenda 失败:{relative_path}: {error}")), + }; + let mut snapshot = AgentAgendaSnapshot::default(); + let mut in_repair_focus = false; + for line in agenda.lines().map(str::trim) { + if let Some(value) = line.strip_prefix("- activeTasks:") { + snapshot.active_task_ids = parse_agent_task_id_list(value); + continue; + } + if let Some(value) = line.strip_prefix("- carriedTasks:") { + snapshot.carried_task_ids = parse_agent_task_id_list(value); + continue; + } + if line == "## Repair Focus" { + in_repair_focus = true; + continue; + } + if line.starts_with("## ") { + in_repair_focus = false; + } + if in_repair_focus { + if let Some(issue) = line.strip_prefix("- ") { + let issue = issue.trim(); + if !issue.is_empty() && !issue.contains("首轮生成") { + snapshot.repair_focus.push(issue.to_string()); + } + } + } + } + Ok(snapshot) +} + +pub(crate) fn collect_agent_pass_plan_traces( + root: &Path, + passes: u8, +) -> Result, String> { + let mut plans = Vec::new(); + for pass in 1..=passes { + let relative_path = format!(".agent/passes/pass-{pass}/task-graph.json"); + let content = match fs::read_to_string(root.join(&relative_path)) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!( + "读取 Agent pass plan 失败:{relative_path}: {error}" + )) + } + }; + let snapshot: AgentPassTaskGraphSnapshot = serde_json::from_str(&content) + .map_err(|error| format!("解析 Agent pass plan 失败:{relative_path}: {error}"))?; + plans.push(GameCreationAgentPassPlanTrace { + pass: if snapshot.pass == 0 { + pass + } else { + snapshot.pass + }, + mode: snapshot.mode, + summary: snapshot.summary, + active_task_ids: snapshot.active_task_ids, + carried_task_ids: snapshot.carried_task_ids, + dependency_waves: snapshot.dependency_waves, + repair_focus: snapshot.repair_focus, + repair_routes: snapshot.repair_routes, + }); + } + Ok(plans) +} + +pub(crate) fn parse_agent_task_id_list(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|task_id| !task_id.is_empty() && *task_id != "none") + .map(str::to_string) + .collect() +} + +pub(crate) fn task_status_from_agent_step( + step: &GameCreationAgentRunStep, + task_id: &str, +) -> Option { + if step.status == "failed" { + return Some(GameCreationAppTaskStatus::Failed); + } + if step.status == "running" { + return Some(GameCreationAppTaskStatus::Running); + } + if step.status == "stopped" && step.phase == "preview" { + return Some(GameCreationAppTaskStatus::WaitingForConfirmation); + } + if step.status == "carried-over" { + return Some(GameCreationAppTaskStatus::Completed); + } + if step.status != "completed" && step.status != "passed" { + return None; + } + + match step.phase.as_str() { + "planning" | "handoff" | "playtest" | "evaluation" => { + Some(GameCreationAppTaskStatus::Completed) + } + "preview" => Some(GameCreationAppTaskStatus::Running), + "role-brief" if role_brief_completes_task(task_id) => { + Some(GameCreationAppTaskStatus::Completed) + } + "role-brief" => Some(GameCreationAppTaskStatus::Running), + _ => None, + } +} + +pub(crate) fn role_brief_completes_task(task_id: &str) -> bool { + matches!( + task_id, + "balance-director" + | "art-director" + | "art-polish" + | "audio-director" + | "code-director" + | "publish-strategy" + ) +} + +pub(crate) fn set_task_status_if_current( + tasks: &mut [GameCreationAppTaskState], + task_id: &str, + status: GameCreationAppTaskStatus, +) { + if let Some(task) = tasks.iter_mut().find(|task| task.id == task_id) { + if should_replace_task_status(&task.status, &status) { + task.status = status; + } + } +} + +pub(crate) fn should_replace_task_status( + current: &GameCreationAppTaskStatus, + next: &GameCreationAppTaskStatus, +) -> bool { + use GameCreationAppTaskStatus as Status; + status_rank(next) >= status_rank(current) + || matches!( + (current, next), + (Status::Running, Status::Completed) + | (Status::WaitingForConfirmation, Status::Running) + | (Status::Pending, _) + ) +} + +pub(crate) fn status_rank(status: &GameCreationAppTaskStatus) -> u8 { + match status { + GameCreationAppTaskStatus::Pending => 0, + GameCreationAppTaskStatus::Running => 1, + GameCreationAppTaskStatus::WaitingForConfirmation => 2, + GameCreationAppTaskStatus::Completed => 3, + GameCreationAppTaskStatus::Failed => 4, + } +} + +pub(crate) fn task_has_status( + tasks: &[GameCreationAppTaskState], + task_id: &str, + status: GameCreationAppTaskStatus, +) -> bool { + tasks + .iter() + .find(|task| task.id == task_id) + .is_some_and(|task| task.status == status) +} + +pub(crate) fn ready_task_ids_for_tasks(tasks: &[GameCreationAppTaskState]) -> Vec { + tasks + .iter() + .filter(|task| { + task.status == GameCreationAppTaskStatus::Pending + && task.dependencies.iter().all(|dependency| { + task_has_status(tasks, dependency, GameCreationAppTaskStatus::Completed) + }) + }) + .map(|task| task.id.clone()) + .collect() +} + +pub(crate) fn write_agent_run_trace( + root: &Path, + run_id: &str, + prompt: &str, + status: &str, + passes: u8, + steps: &[GameCreationAgentRunStep], + error: Option<&str>, +) -> Result<(), String> { + let tool_call_count = count_agent_tool_calls(steps)?; + let trace = GameCreationAgentRunTrace { + schema_version: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION.to_string(), + run_id: run_id.to_string(), + command_id: "game.generate_draft".to_string(), + status: status.to_string(), + lifecycle_status: Some(agent_run_lifecycle_status(status).to_string()), + passes, + max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES, + tool_call_count, + max_tool_calls: GAME_CREATOR_AGENT_TOOL_CALL_MAX, + stop_reason: agent_run_stop_reason(status, error).to_string(), + goal: prompt.trim().to_string(), + coordination: "filesystem".to_string(), + steps: steps.to_vec(), + artifacts: collect_agent_run_artifacts(root)?, + task_graph: build_agent_run_task_graph_trace(root, prompt, passes, steps)?, + pass_plans: collect_agent_pass_plan_traces(root, passes)?, + next_step: match status { + "passed" => "preview-playtest", + "artifacts-written" => "game.static_smoke", + "failed" => "inspect-error", + _ => "generator-revision", + } + .to_string(), + error: error.map(str::to_string), + updated_at: unix_timestamp(), + }; + write_agent_run_trace_payload(root, &trace) +} + +pub(crate) fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'static str { + match status { + "running" => "loop-running", + "needs-revision" => "evaluator-needs-revision", + "passed" => "evaluator-passed", + "artifacts-written" => "artifacts-written", + "preview-running" => "preview-running", + "preview-stopped" => "preview-stopped", + "failed" if error.is_some_and(|message| message.contains("已重试")) => { + "max-passes-exhausted" + } + "failed" => "failed", + _ => "unknown", + } +} + +pub(crate) fn agent_run_lifecycle_status(status: &str) -> &'static str { + match status { + "running" | "needs-revision" | "artifacts-written" | "preview-running" => "running", + "waiting" | "preview-stopped" => "waiting", + "pending" => "pending", + "killed" => "killed", + "failed" => "failed", + "passed" => "done", + _ => "scheduled", + } +} + +pub(crate) fn append_agent_run_activity( + root: &Path, + run_id: &str, + event: &str, + message: &str, +) -> Result<(), String> { + append_agent_run_jsonl( + root, + ".agent/activity.jsonl", + &serde_json::json!({ + "timestamp": unix_timestamp(), + "runId": run_id, + "event": event, + "message": message, + }), + ) +} + +pub(crate) fn append_agent_run_output( + root: &Path, + run_id: &str, + event: &str, + message: &str, +) -> Result<(), String> { + append_agent_run_jsonl( + root, + ".agent/output.jsonl", + &serde_json::json!({ + "timestamp": unix_timestamp(), + "runId": run_id, + "event": event, + "content": message, + }), + ) +} + +pub(crate) fn append_agent_run_jsonl( + root: &Path, + relative_path: &str, + value: &serde_json::Value, +) -> Result<(), String> { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?; + } + let line = + serde_json::to_string(value).map_err(|error| format!("序列化 Agent 事件失败:{error}"))?; + append_jsonl_line(&path, &line, "Agent 事件") +} + +pub(crate) fn write_agent_run_context_bundle( + root: &Path, + trace: &GameCreationAgentRunTrace, +) -> Result<(), String> { + let bundle_path = root.join(".agent/context.bundle.json"); + if let Some(parent) = bundle_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent context bundle 目录失败:{}: {error}", + parent.display() + ) + })?; + } + let manifest = read_manifest_for_project(root).ok(); + let payload = serde_json::json!({ + "schemaVersion": "game-creator-context-bundle.v1", + "runId": trace.run_id, + "commandId": trace.command_id, + "goal": trace.goal, + "lifecycleStatus": trace.lifecycle_status.as_deref().unwrap_or_else(|| agent_run_lifecycle_status(&trace.status)), + "status": trace.status, + "nextStep": trace.next_step, + "memory": { + "short": "memory/session.md", + "long": "memory/project.md", + "blackboard": PROJECT_BLACKBOARD_MEMORY_PATH, + "agents": "memory/agents/" + }, + "manifest": manifest, + "trace": ".agent/run.latest.json", + "activity": ".agent/activity.jsonl", + "output": ".agent/output.jsonl", + "updatedAt": unix_timestamp() + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成 Agent context bundle 失败:{error}"))?; + fs::write(&bundle_path, content).map_err(|error| { + format!( + "写入 Agent context bundle 失败:{}: {error}", + bundle_path.display() + ) + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index f9f4762e2..aded90b99 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -1,4036 +1,24 @@ -use std::collections::{HashMap, HashSet}; -#[cfg(test)] -use std::env; -use std::fs; -use std::io::Write; -use std::net::Ipv4Addr; -use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +mod capture; +mod cdp; +mod discovery; +mod evidence; +mod model; +mod network_policy; +mod playtest; +mod process; -use chromiumoxide::browser::{Browser, BrowserConfig}; -use chromiumoxide::cdp::browser_protocol::browser::{ - SetDownloadBehaviorBehavior, SetDownloadBehaviorParams, +pub use discovery::discover_chrome_or_edge; +#[allow(unused_imports)] +pub use model::{ + BrowserCanvasProbe, BrowserConsoleMessage, BrowserException, BrowserExpectedTextMatch, + BrowserFailedRequest, BrowserIdentity, BrowserPlaytestAssertion, BrowserPlaytestPhase, + BrowserPlaytestResult, BrowserPlaytestScenario, BrowserValidationEvidencePaths, + BrowserValidationInput, BrowserValidationResult, BrowserValidationViewport, + BrowserViewportValidationResult, DiscoveredBrowser, DiscoveredBrowserKind, }; -use chromiumoxide::cdp::browser_protocol::emulation::{ - SetDeviceMetricsOverrideParams, SetTouchEmulationEnabledParams, -}; -use chromiumoxide::cdp::browser_protocol::fetch::{ - ContinueRequestParams, EnableParams as FetchEnableParams, EventRequestPaused, - FailRequestParams, RequestPattern, RequestStage, -}; -use chromiumoxide::cdp::browser_protocol::network::{ - ErrorReason, EventLoadingFailed, EventRequestWillBeSent, EventResponseReceived, - EventWebSocketCreated, EventWebSocketFrameError, EventWebSocketWillSendHandshakeRequest, - ResourceType, SetBypassServiceWorkerParams, -}; -use chromiumoxide::cdp::browser_protocol::page::{ - CaptureScreenshotFormat, EventJavascriptDialogOpening, HandleJavaScriptDialogParams, -}; -use chromiumoxide::cdp::js_protocol::runtime::{ - ConsoleApiCalledType, EventConsoleApiCalled, EventExceptionThrown, RemoteObject, -}; -use chromiumoxide::page::ScreenshotParams; -use chromiumoxide::Page; -use futures::StreamExt; -use serde::{Deserialize, Deserializer, Serialize}; -use sha2::{Digest, Sha256}; -use tempfile::{Builder as TempDirBuilder, NamedTempFile, TempDir}; -use tokio::task::JoinHandle; -use tokio::time::Instant; -use url::{Host, Url}; +pub use process::validate_local_preview_in_browser; -const RESULT_SCHEMA_VERSION: &str = "browser-validation.v1"; -const DEFAULT_SETTLE_MS: u64 = 800; -const MAX_SETTLE_MS: u64 = 30_000; -const MAX_EXPECTED_TEXT_ITEMS: usize = 32; -const MAX_EXPECTED_TEXT_CHARS: usize = 512; -const MAX_VISIBLE_TEXT_CHARS: usize = 4_000; -const MAX_EVENT_TEXT_CHARS: usize = 2_000; -const MAX_CAPTURED_EVENTS: usize = 100; -const MAX_TRACKED_REQUESTS: usize = 2_048; -const MAX_URL_CHARS: usize = 2_048; -const BROWSER_TIMEOUT: Duration = Duration::from_secs(30); -const PLAYABLE_GAME_STATE_SCHEMA_VERSION: &str = "playable-web-game-state.v1"; -const MAX_PLAYABLE_GAME_STATE_JSON_CHARS: usize = 128 * 1024; -const MAX_PLAYABLE_GAME_COLLECTION_ITEMS: usize = 4_096; -const MAX_PLAYABLE_GAME_ID_CHARS: usize = 256; -const PLAYTEST_TOTAL_TIMEOUT: Duration = Duration::from_secs(30); -const PLAYTEST_POLL_INTERVAL: Duration = Duration::from_millis(50); - -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum BrowserValidationViewport { - Desktop, - Mobile, -} - -const REQUIRED_VIEWPORTS: [BrowserValidationViewport; 2] = [ - BrowserValidationViewport::Desktop, - BrowserValidationViewport::Mobile, -]; - -impl BrowserValidationViewport { - fn dimensions(self) -> (u32, u32, bool) { - match self { - Self::Desktop => (1280, 720, false), - Self::Mobile => (390, 844, true), - } - } - - fn file_stem(self) -> &'static str { - match self { - Self::Desktop => "desktop", - Self::Mobile => "mobile", - } - } -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum BrowserPlaytestScenario { - GenericV1, - LaneDefenseV1, -} - -impl BrowserPlaytestScenario { - fn as_str(self) -> &'static str { - match self { - Self::GenericV1 => "generic-v1", - Self::LaneDefenseV1 => "lane-defense-v1", - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct BrowserValidationInput { - pub url: String, - #[serde(deserialize_with = "deserialize_fixed_viewports")] - pub viewports: Vec, - #[serde(default)] - pub expected_text: Vec, - #[serde(default = "default_settle_ms")] - pub settle_ms: u64, - #[serde(default = "default_fail_on_console_error")] - pub fail_on_console_error: bool, - #[serde(default)] - pub playtest_scenario: Option, - pub evidence_root: PathBuf, -} - -fn default_settle_ms() -> u64 { - DEFAULT_SETTLE_MS -} - -fn default_fail_on_console_error() -> bool { - true -} - -fn validate_fixed_viewports(viewports: &[BrowserValidationViewport]) -> Result<(), String> { - if viewports.len() != REQUIRED_VIEWPORTS.len() { - return Err("viewports 必须且只能同时包含 desktop 和 mobile".to_string()); - } - let mut unique = HashSet::new(); - if viewports.iter().any(|viewport| !unique.insert(*viewport)) { - return Err("viewports 不能重复".to_string()); - } - if REQUIRED_VIEWPORTS - .iter() - .any(|required| !unique.contains(required)) - { - return Err("viewports 只能包含 desktop 和 mobile".to_string()); - } - Ok(()) -} - -fn deserialize_fixed_viewports<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let viewports = Vec::::deserialize(deserializer)?; - validate_fixed_viewports(&viewports).map_err(serde::de::Error::custom)?; - Ok(viewports) -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum DiscoveredBrowserKind { - Chrome, - Edge, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DiscoveredBrowser { - pub kind: DiscoveredBrowserKind, - pub executable_path: PathBuf, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserIdentity { - pub kind: DiscoveredBrowserKind, - pub product: String, - pub protocol_version: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserValidationEvidencePaths { - pub root: PathBuf, - pub report_path: PathBuf, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserExpectedTextMatch { - pub text: String, - pub found: bool, -} - -#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum BrowserPlaytestPhase { - Ready, - Playing, - Won, - Lost, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct BrowserPlaytestAssertion { - pub name: String, - pub passed: bool, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct BrowserPlaytestResult { - pub scenario: BrowserPlaytestScenario, - pub scenario_fingerprint: String, - pub passed: bool, - pub initial_sequence: Option, - pub initial_phase: Option, - pub initial_level: Option, - pub final_sequence: Option, - pub final_phase: Option, - pub final_level: Option, - pub assertions: Vec, - pub diagnostics: Vec, -} - -const GENERIC_PLAYTEST_ASSERTIONS: &[&str] = &[ - "state-surface-valid", - "start-control-clicked", - "start-sequence-advanced", - "start-phase-playing-or-won", - "restart-control-clicked", - "restart-sequence-advanced", - "restart-phase-ready-or-playing", -]; - -const LANE_DEFENSE_PLAYTEST_ASSERTIONS: &[&str] = &[ - "state-surface-valid", - "initial-phase-ready", - "level-positive", - "start-control-visible", - "start-control-enabled", - "start-control-clicked", - "start-sequence-advanced", - "start-phase-playing", - "defender-option-control-visible", - "defender-option-control-enabled", - "defender-option-control-clicked", - "defender-selection-sequence-advanced", - "defender-selection-recorded", - "lane-cell-control-visible", - "lane-cell-control-enabled", - "lane-cell-control-clicked", - "defender-placement-sequence-advanced", - "defender-count-increased", - "enemies-present-after-placement", - "speed-up-control-visible", - "speed-up-control-enabled", - "speed-up-control-clicked", - "battle-sequence-advanced", - "battle-sequence-monotonic", - "enemy-position-changed", - "enemy-health-decreased", - "phase-won", - "next-level-control-visible", - "next-level-control-enabled", - "next-level-control-clicked", - "next-level-sequence-advanced", - "level-increased", - "restart-control-visible", - "restart-control-enabled", - "restart-control-clicked", - "restart-sequence-advanced", - "restart-phase-ready-or-playing", -]; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserConsoleMessage { - pub level: String, - pub text: String, - pub source_url: Option, - pub line_number: Option, - pub column_number: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserException { - pub text: String, - pub source_url: Option, - pub line_number: u32, - pub column_number: u32, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserFailedRequest { - pub url: String, - pub method: String, - pub resource_type: String, - pub error_text: String, - pub status_code: Option, - pub canceled: bool, - pub blocked_by_policy: bool, - pub fatal: bool, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserCanvasProbe { - pub width: u32, - pub height: u32, - pub css_width: f64, - pub css_height: f64, - pub visible_area: f64, - pub sample_count: u32, - pub non_empty_pixel_count: u32, - pub non_empty: Option, - pub probe_error: Option, -} - -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -struct BrowserCanvasSnapshot { - width: u32, - height: u32, - css_width: f64, - css_height: f64, - visible_area: f64, - sample_count: u32, - non_empty_pixel_count: u32, - distinct_pixel_state_count: u32, - probe_error: Option, -} - -impl BrowserCanvasSnapshot { - fn into_evidence(self) -> BrowserCanvasProbe { - let non_empty = if self.probe_error.is_some() { - None - } else { - classify_canvas_pixel_probe( - self.sample_count, - self.non_empty_pixel_count, - self.distinct_pixel_state_count, - ) - }; - BrowserCanvasProbe { - width: self.width, - height: self.height, - css_width: self.css_width, - css_height: self.css_height, - visible_area: self.visible_area, - sample_count: self.sample_count, - non_empty_pixel_count: self.non_empty_pixel_count, - non_empty, - probe_error: self.probe_error, - } - } -} - -fn classify_canvas_pixel_probe( - sample_count: u32, - non_empty_pixel_count: u32, - distinct_pixel_state_count: u32, -) -> Option { - if sample_count == 0 { - return None; - } - Some( - non_empty_pixel_count > 0 - && non_empty_pixel_count <= sample_count - && distinct_pixel_state_count >= 2 - && distinct_pixel_state_count <= sample_count, - ) -} - -fn canvas_validation_diagnostic(canvases: &[BrowserCanvasProbe]) -> Option<&'static str> { - let mut visible_canvases = canvases.iter().filter(|canvas| canvas.visible_area > 0.0); - let Some(first_visible) = visible_canvases.next() else { - return Some("未发现可见 canvas"); - }; - if first_visible.non_empty == Some(true) - || visible_canvases.any(|canvas| canvas.non_empty == Some(true)) - { - None - } else { - Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") - } -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserViewportValidationResult { - pub viewport: BrowserValidationViewport, - pub width: u32, - pub height: u32, - pub final_url: String, - pub title: String, - pub ready_state: String, - pub visible_text_summary: String, - pub visible_text_character_count: usize, - pub dom_character_count: usize, - pub expected_text: Vec, - pub console_errors: Vec, - pub console_warnings: Vec, - pub exceptions: Vec, - pub failed_requests: Vec, - pub canvases: Vec, - pub blocked_popup_count: u32, - pub blocked_dialog_count: u32, - pub blocked_download_count: u32, - pub blocked_permission_count: u32, - pub blocked_service_worker_count: u32, - pub screenshot_path: PathBuf, - pub passed: bool, - pub diagnostics: Vec, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BrowserValidationResult { - pub schema_version: String, - pub url: String, - pub browser: BrowserIdentity, - pub passed: bool, - pub viewport_results: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub playtest: Option, - pub diagnostics: Vec, - pub evidence: BrowserValidationEvidencePaths, - pub completed_at_unix_ms: u64, -} - -#[derive(Clone, Debug)] -struct RequestInfo { - url: String, - method: String, - resource_type: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PreviewRequestBlockReason { - CrossOrigin, - RedirectTarget, - WebSocketBeforeHandshake, -} - -impl PreviewRequestBlockReason { - fn message(self) -> &'static str { - match self { - Self::CrossOrigin => "blocked by preview origin policy before request", - Self::RedirectTarget => "blocked cross-origin redirect before request", - Self::WebSocketBeforeHandshake => "blocked cross-origin WebSocket before handshake", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PreviewRequestDecision { - Allow, - Block(PreviewRequestBlockReason), -} - -#[derive(Default)] -struct CaptureState { - requests: HashMap, - console_errors: Vec, - console_warnings: Vec, - exceptions: Vec, - failed_requests: Vec, - blocked_dialog_count: u32, - infrastructure_errors: Vec, -} - -impl CaptureState { - fn push_failed_request(&mut self, request: BrowserFailedRequest) { - if self.failed_requests.len() >= MAX_CAPTURED_EVENTS { - return; - } - if !self.failed_requests.iter().any(|existing| { - existing.url == request.url - && existing.method == request.method - && existing.error_text == request.error_text - && existing.status_code == request.status_code - }) { - self.failed_requests.push(request); - } - } - - fn push_infrastructure_error(&mut self, error: String) { - if self.infrastructure_errors.len() < 8 { - self.infrastructure_errors - .push(truncate_chars(&error, MAX_EVENT_TEXT_CHARS)); - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PageSnapshot { - final_url: String, - title: String, - ready_state: String, - visible_text_summary: String, - visible_text_character_count: usize, - dom_character_count: usize, - expected_text_matches: Vec, - canvases: Vec, - blocked_popup_count: u32, - blocked_download_count: u32, - blocked_permission_count: u32, - blocked_service_worker_count: u32, -} - -#[derive(Clone, Debug)] -struct PlayableWebGameState { - sequence: u64, - phase: BrowserPlaytestPhase, - level: u64, - selected_defender_id: Option, - defender_count: Option, - enemies: Option>, -} - -#[derive(Clone, Debug)] -struct PlayableEnemyState { - id: String, - position: f64, - health: f64, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PlayableStateSurfaceRead { - status: String, - content_length: usize, - content: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PlaytestControlProbe { - is_html_element: bool, - visible: bool, - disabled: bool, -} - -struct PlaytestPollOutcome { - matched: bool, - last_state: Option, -} - -struct LaneBattleProgress { - baseline_sequence: u64, - previous_state: PlayableWebGameState, - sequence_advanced: bool, - sequence_monotonic: bool, - enemy_position_changed: bool, - enemy_health_decreased: bool, -} - -impl LaneBattleProgress { - fn new(initial: PlayableWebGameState) -> Self { - Self { - baseline_sequence: initial.sequence, - previous_state: initial, - sequence_advanced: false, - sequence_monotonic: true, - enemy_position_changed: false, - enemy_health_decreased: false, - } - } - - fn observe(&mut self, state: &PlayableWebGameState) { - self.sequence_advanced |= state.sequence > self.baseline_sequence; - self.sequence_monotonic &= state.sequence >= self.previous_state.sequence; - let (position_changed, health_decreased) = - lane_enemy_state_changes(&self.previous_state, state); - self.enemy_position_changed |= position_changed; - self.enemy_health_decreased |= health_decreased; - self.previous_state = state.clone(); - } - - fn completed(&self, state: &PlayableWebGameState) -> bool { - self.sequence_advanced - && self.sequence_monotonic - && self.enemy_position_changed - && self.enemy_health_decreased - && state.phase == BrowserPlaytestPhase::Won - } -} - -struct BrowserViewportValidationOutcome { - result: BrowserViewportValidationResult, - playtest: Option, -} - -impl BrowserPlaytestScenario { - fn assertion_names(self) -> &'static [&'static str] { - match self { - Self::GenericV1 => GENERIC_PLAYTEST_ASSERTIONS, - Self::LaneDefenseV1 => LANE_DEFENSE_PLAYTEST_ASSERTIONS, - } - } -} - -impl BrowserPlaytestResult { - fn pending(scenario: BrowserPlaytestScenario) -> Self { - Self { - scenario, - scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario), - passed: false, - initial_sequence: None, - initial_phase: None, - initial_level: None, - final_sequence: None, - final_phase: None, - final_level: None, - assertions: scenario - .assertion_names() - .iter() - .map(|name| BrowserPlaytestAssertion { - name: (*name).to_string(), - passed: false, - }) - .collect(), - diagnostics: Vec::new(), - } - } - - fn record_initial_state(&mut self, state: &PlayableWebGameState) { - self.initial_sequence = Some(state.sequence); - self.initial_phase = Some(state.phase); - self.initial_level = Some(state.level); - self.record_final_state(state); - } - - fn record_final_state(&mut self, state: &PlayableWebGameState) { - self.final_sequence = Some(state.sequence); - self.final_phase = Some(state.phase); - self.final_level = Some(state.level); - } - - fn set_assertion(&mut self, name: &str, passed: bool) { - if let Some(assertion) = self - .assertions - .iter_mut() - .find(|assertion| assertion.name == name) - { - assertion.passed = passed; - } else { - self.push_diagnostic("内部试玩断言配置无效"); - } - } - - fn push_diagnostic(&mut self, diagnostic: impl Into) { - if self.diagnostics.len() >= 32 { - return; - } - let diagnostic = truncate_chars(&diagnostic.into(), 512); - if !self.diagnostics.contains(&diagnostic) { - self.diagnostics.push(diagnostic); - } - } - - fn finish(mut self) -> Self { - let failed_assertions = self - .assertions - .iter() - .filter(|assertion| !assertion.passed) - .map(|assertion| assertion.name.clone()) - .collect::>(); - if !failed_assertions.is_empty() { - self.push_diagnostic(format!( - "未通过固定试玩断言:{}", - failed_assertions.join("、") - )); - } - self.passed = browser_playtest_assertions_passed(&self.assertions, &self.diagnostics); - self - } -} - -fn browser_playtest_assertions_passed( - assertions: &[BrowserPlaytestAssertion], - diagnostics: &[String], -) -> bool { - !assertions.is_empty() - && assertions.iter().all(|assertion| assertion.passed) - && diagnostics.is_empty() -} - -const PLAYABLE_STATE_CONTRACT_FINGERPRINT_MATERIAL: &str = concat!( - "surface=script#playable-web-game-state[type=application/json]\n", - "schemaVersion=playable-web-game-state.v1\n", - "base=sequence:u64,phase:ready|playing|won|lost,level:u64\n", - "lane=selectedDefenderId:null|string,defenders:array,", - "enemies:[id,lane,position,health,maxHealth],level>0\n", - "observation=action-sequence-strict,cross-observation-monotonic,", - "missing-enemy-health-zero" -); - -pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestScenario) -> String { - let mut hasher = Sha256::new(); - update_playtest_fingerprint_component(&mut hasher, "browser-playtest-scenario-fingerprint.v1"); - update_playtest_fingerprint_component(&mut hasher, scenario.as_str()); - update_playtest_fingerprint_component( - &mut hasher, - &format!( - concat!( - "viewport=desktop\n", - "click=chromiumoxide-element-mouse-input\n", - "control=unique-visible-enabled\n", - "totalTimeoutMs={}\npollIntervalMs={}" - ), - PLAYTEST_TOTAL_TIMEOUT.as_millis(), - PLAYTEST_POLL_INTERVAL.as_millis() - ), - ); - update_playtest_fingerprint_component( - &mut hasher, - PLAYABLE_STATE_CONTRACT_FINGERPRINT_MATERIAL, - ); - update_playtest_fingerprint_component(&mut hasher, READ_PLAYABLE_GAME_STATE_SCRIPT); - update_playtest_fingerprint_component(&mut hasher, PROBE_PLAYTEST_CONTROL_SCRIPT); - match scenario { - BrowserPlaytestScenario::GenericV1 => { - update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR); - update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR); - } - BrowserPlaytestScenario::LaneDefenseV1 => { - for selector in [ - PLAYTEST_START_SELECTOR, - PLAYTEST_DEFENDER_OPTION_SELECTOR, - PLAYTEST_LANE_CELL_SELECTOR, - PLAYTEST_SPEED_UP_SELECTOR, - PLAYTEST_NEXT_LEVEL_SELECTOR, - PLAYTEST_RESTART_SELECTOR, - ] { - update_playtest_fingerprint_component(&mut hasher, selector); - } - } - } - for assertion in scenario.assertion_names() { - update_playtest_fingerprint_component(&mut hasher, assertion); - } - format!("{:x}", hasher.finalize()) -} - -fn update_playtest_fingerprint_component(hasher: &mut Sha256, component: &str) { - hasher.update((component.len() as u64).to_be_bytes()); - hasher.update(component.as_bytes()); -} - -struct CaptureTasks { - state: Arc>, - handles: Vec>, -} - -impl CaptureTasks { - async fn stop(self) -> Result { - for handle in &self.handles { - handle.abort(); - } - for handle in self.handles { - let _ = handle.await; - } - Arc::try_unwrap(self.state) - .map_err(|_| "browser capture state is still in use".to_string())? - .into_inner() - .map_err(|_| "browser capture state lock is poisoned".to_string()) - } -} - -pub fn discover_chrome_or_edge() -> Result { - let mut seen = HashSet::new(); - for (path, kind) in system_browser_candidates() { - if !path.is_absolute() { - continue; - } - let canonical = path.canonicalize().unwrap_or(path); - if seen.insert(canonical.clone()) && is_executable_file(&canonical) { - return Ok(DiscoveredBrowser { - kind, - executable_path: canonical, - }); - } - } - Err("未发现可用的 Google Chrome、Chromium 或 Microsoft Edge".to_string()) -} - -fn system_browser_candidates() -> Vec<(PathBuf, DiscoveredBrowserKind)> { - let mut candidates = Vec::new(); - append_platform_candidates(&mut candidates); - candidates -} - -#[cfg(target_os = "linux")] -fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) { - candidates.extend([ - ( - PathBuf::from("/opt/google/chrome/chrome"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/opt/google/chrome/google-chrome"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/usr/bin/google-chrome-stable"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/usr/bin/google-chrome"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/usr/bin/chromium"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/usr/bin/chromium-browser"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/usr/lib/chromium/chromium"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/usr/lib/chromium-browser/chromium-browser"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/opt/microsoft/msedge/msedge"), - DiscoveredBrowserKind::Edge, - ), - ( - PathBuf::from("/usr/bin/microsoft-edge-stable"), - DiscoveredBrowserKind::Edge, - ), - ( - PathBuf::from("/usr/bin/microsoft-edge"), - DiscoveredBrowserKind::Edge, - ), - ]); -} - -#[cfg(target_os = "macos")] -fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) { - candidates.extend([ - ( - PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"), - DiscoveredBrowserKind::Chrome, - ), - ( - PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), - DiscoveredBrowserKind::Edge, - ), - ]); -} - -#[cfg(target_os = "windows")] -fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) { - for folder_id in [ - &FOLDER_ID_PROGRAM_FILES, - &FOLDER_ID_PROGRAM_FILES_X86, - &FOLDER_ID_LOCAL_APP_DATA, - ] { - let Some(root) = windows_known_folder_path(folder_id) else { - continue; - }; - candidates.push(( - root.join("Google/Chrome/Application/chrome.exe"), - DiscoveredBrowserKind::Chrome, - )); - candidates.push(( - root.join("Chromium/Application/chrome.exe"), - DiscoveredBrowserKind::Chrome, - )); - candidates.push(( - root.join("Microsoft/Edge/Application/msedge.exe"), - DiscoveredBrowserKind::Edge, - )); - } -} - -#[cfg(target_os = "windows")] -#[repr(C)] -struct WindowsGuid { - data1: u32, - data2: u16, - data3: u16, - data4: [u8; 8], -} - -#[cfg(target_os = "windows")] -const FOLDER_ID_PROGRAM_FILES: WindowsGuid = WindowsGuid { - data1: 0x905e63b6, - data2: 0xc1bf, - data3: 0x494e, - data4: [0xb2, 0x9c, 0x65, 0xb7, 0x32, 0xd3, 0xd2, 0x1a], -}; - -#[cfg(target_os = "windows")] -const FOLDER_ID_PROGRAM_FILES_X86: WindowsGuid = WindowsGuid { - data1: 0x7c5a40ef, - data2: 0xa0fb, - data3: 0x4bfc, - data4: [0x87, 0x4a, 0xc0, 0xf2, 0xe0, 0xb9, 0xfa, 0x8e], -}; - -#[cfg(target_os = "windows")] -const FOLDER_ID_LOCAL_APP_DATA: WindowsGuid = WindowsGuid { - data1: 0xf1b32785, - data2: 0x6fba, - data3: 0x4fcf, - data4: [0x9d, 0x55, 0x7b, 0x8e, 0x7f, 0x15, 0x70, 0x91], -}; - -#[cfg(target_os = "windows")] -#[link(name = "shell32")] -extern "system" { - fn SHGetKnownFolderPath( - folder_id: *const WindowsGuid, - flags: u32, - token: *mut std::ffi::c_void, - path: *mut *mut u16, - ) -> i32; -} - -#[cfg(target_os = "windows")] -#[link(name = "ole32")] -extern "system" { - fn CoTaskMemFree(value: *mut std::ffi::c_void); -} - -#[cfg(target_os = "windows")] -fn windows_known_folder_path(folder_id: &WindowsGuid) -> Option { - use std::ffi::OsString; - use std::os::windows::ffi::OsStringExt; - use std::ptr; - use std::slice; - - let mut raw_path = ptr::null_mut(); - let result = unsafe { SHGetKnownFolderPath(folder_id, 0, ptr::null_mut(), &mut raw_path) }; - if result < 0 || raw_path.is_null() { - return None; - } - let mut length = 0; - while unsafe { *raw_path.add(length) } != 0 { - length += 1; - } - let path = PathBuf::from(OsString::from_wide(unsafe { - slice::from_raw_parts(raw_path, length) - })); - unsafe { CoTaskMemFree(raw_path.cast()) }; - path.is_absolute().then_some(path) -} - -#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] -fn append_platform_candidates(_candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) {} - -fn is_executable_file(path: &Path) -> bool { - let Ok(metadata) = fs::metadata(path) else { - return false; - }; - if !metadata.is_file() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - true - } -} - -fn browser_process_temp_root() -> PathBuf { - #[cfg(unix)] - { - PathBuf::from("/tmp") - } - #[cfg(not(unix))] - { - std::env::temp_dir() - } -} - -fn create_browser_process_temp_dir() -> Result { - TempDirBuilder::new() - .prefix("ga-browser-") - .tempdir_in(browser_process_temp_root()) - .map_err(|error| format!("创建浏览器临时目录失败:{error}")) -} - -pub async fn validate_local_preview_in_browser( - input: BrowserValidationInput, -) -> Result { - let preview_url = validate_input(&input)?; - prepare_evidence_root(&input.evidence_root)?; - let browser_executable = discover_chrome_or_edge()?; - let browser_temp = create_browser_process_temp_dir()?; - let profile_path = browser_temp.path().join("profile"); - fs::create_dir(&profile_path) - .map_err(|error| format!("创建浏览器临时 Profile 失败:{error}"))?; - let browser_temp_path = browser_temp.path().to_string_lossy().into_owned(); - let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); - - let config = BrowserConfig::builder() - .chrome_executable(&browser_executable.executable_path) - .user_data_dir(profile_path) - .env("TMPDIR", browser_temp_path) - .new_headless_mode() - .enable_request_intercept() - .disable_cache() - .disable_https_first() - .request_timeout(BROWSER_TIMEOUT) - .launch_timeout(BROWSER_TIMEOUT) - .window_size(1280, 720) - .arg(("proxy-server", "http://127.0.0.1:9")) - .arg(("proxy-bypass-list", proxy_bypass_list.as_str())) - .arg("block-new-web-contents") - .arg("deny-permission-prompts") - .arg("disable-notifications") - .arg("disable-service-worker") - .build() - .map_err(|error| format!("构建浏览器配置失败:{error}"))?; - - let (mut browser, mut handler) = tokio::time::timeout(BROWSER_TIMEOUT, Browser::launch(config)) - .await - .map_err(|_| "启动浏览器超时".to_string())? - .map_err(|error| format!("启动浏览器失败:{error}"))?; - let handler_task = tokio::spawn(async move { - while let Some(message) = handler.next().await { - if message.is_err() { - break; - } - } - }); - - let validation = - run_browser_validation(&browser, &browser_executable, &preview_url, &input).await; - - let close_result = browser - .close() - .await - .map_err(|error| format!("关闭浏览器失败:{error}")); - let wait_result = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; - handler_task.abort(); - let _ = handler_task.await; - drop(browser_temp); - - let mut result = validation?; - close_result?; - match wait_result { - Ok(Ok(_)) => {} - Ok(Err(error)) => return Err(format!("等待浏览器退出失败:{error}")), - Err(_) => return Err("等待浏览器退出超时".to_string()), - } - result.completed_at_unix_ms = unix_time_ms(); - let persisted_result = browser_validation_result_for_report(&result)?; - write_json_report(&result.evidence.report_path, &persisted_result)?; - Ok(result) -} - -fn browser_validation_result_for_report( - result: &BrowserValidationResult, -) -> Result { - let evidence_root = &result.evidence.root; - let expected_report_path = evidence_root.join("validation.json"); - if result.evidence.report_path != expected_report_path { - return Err("浏览器验证报告路径与证据目录不匹配".to_string()); - } - - let mut persisted = result.clone(); - persisted.evidence.root = PathBuf::from("."); - persisted.evidence.report_path = PathBuf::from("validation.json"); - for viewport in &mut persisted.viewport_results { - let relative = viewport - .screenshot_path - .strip_prefix(evidence_root) - .map_err(|_| "浏览器验证截图路径不在证据目录内".to_string())?; - if relative.as_os_str().is_empty() || relative.components().count() != 1 { - return Err("浏览器验证截图路径不是证据目录内的直接文件".to_string()); - } - viewport.screenshot_path = relative.to_path_buf(); - } - Ok(persisted) -} - -async fn run_browser_validation( - browser: &Browser, - discovered: &DiscoveredBrowser, - preview_url: &Url, - input: &BrowserValidationInput, -) -> Result { - browser - .execute(SetDownloadBehaviorParams::new( - SetDownloadBehaviorBehavior::Deny, - )) - .await - .map_err(|error| format!("禁用浏览器下载失败:{error}"))?; - let version = browser - .version() - .await - .map_err(|error| format!("读取浏览器版本失败:{error}"))?; - - let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len()); - let mut playtest = None; - for viewport in REQUIRED_VIEWPORTS { - let outcome = validate_viewport(browser, preview_url, input, viewport).await?; - if outcome.playtest.is_some() { - playtest = outcome.playtest; - } - viewport_results.push(outcome.result); - } - let mut diagnostics = viewport_results - .iter() - .flat_map(|result| { - result - .diagnostics - .iter() - .map(move |message| format!("{}: {message}", result.viewport.file_stem())) - }) - .collect::>(); - let playtest_passed = match (&input.playtest_scenario, &playtest) { - (None, None) => true, - (Some(_), Some(result)) => { - if !result.passed { - diagnostics.extend( - result - .diagnostics - .iter() - .map(|message| format!("playtest: {message}")), - ); - } - result.passed - } - (Some(_), None) => { - diagnostics.push("playtest: desktop 试玩结果缺失".to_string()); - false - } - (None, Some(_)) => { - diagnostics.push("playtest: 未请求试玩却产生了试玩结果".to_string()); - false - } - }; - let passed = viewport_results.iter().all(|result| result.passed) && playtest_passed; - let report_path = input.evidence_root.join("validation.json"); - - Ok(BrowserValidationResult { - schema_version: RESULT_SCHEMA_VERSION.to_string(), - url: preview_url.as_str().to_string(), - browser: BrowserIdentity { - kind: discovered.kind, - product: version.product, - protocol_version: version.protocol_version, - }, - passed, - viewport_results, - playtest, - diagnostics, - evidence: BrowserValidationEvidencePaths { - root: input.evidence_root.clone(), - report_path, - }, - completed_at_unix_ms: 0, - }) -} - -async fn validate_viewport( - browser: &Browser, - preview_url: &Url, - input: &BrowserValidationInput, - viewport: BrowserValidationViewport, -) -> Result { - let (width, height, mobile) = viewport.dimensions(); - let page = browser - .new_page("about:blank") - .await - .map_err(|error| format!("创建 {} 页面失败:{error}", viewport.file_stem()))?; - page.execute(SetDeviceMetricsOverrideParams::new( - i64::from(width), - i64::from(height), - 1.0, - mobile, - )) - .await - .map_err(|error| format!("设置 {} 视口失败:{error}", viewport.file_stem()))?; - page.execute(SetTouchEmulationEnabledParams::new(mobile)) - .await - .map_err(|error| format!("设置触摸模拟失败:{error}"))?; - page.execute(SetBypassServiceWorkerParams::new(true)) - .await - .map_err(|error| format!("绕过 Service Worker 失败:{error}"))?; - page.evaluate_on_new_document(RESTRICTION_SCRIPT) - .await - .map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?; - - let tasks = start_capture_tasks(&page, preview_url).await?; - let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str())) - .await - .map_err(|_| format!("{} 页面导航超时", viewport.file_stem()))?; - if let Err(error) = navigation { - let _ = tasks.stop().await; - let _ = page.close().await; - return Err(format!("{} 页面导航失败:{error}", viewport.file_stem())); - } - tokio::time::sleep(Duration::from_millis(input.settle_ms)).await; - - let playtest = if viewport == BrowserValidationViewport::Desktop { - match input.playtest_scenario { - Some(scenario) => Some(run_desktop_playtest(&page, scenario).await), - None => None, - } - } else { - None - }; - - let snapshot_script = build_snapshot_script(&input.expected_text)?; - let snapshot: PageSnapshot = page - .evaluate(snapshot_script) - .await - .map_err(|error| format!("采集 {} 页面状态失败:{error}", viewport.file_stem()))? - .into_value() - .map_err(|error| format!("解析 {} 页面状态失败:{error}", viewport.file_stem()))?; - let screenshot = page - .screenshot( - ScreenshotParams::builder() - .format(CaptureScreenshotFormat::Png) - .full_page(false) - .capture_beyond_viewport(false) - .build(), - ) - .await - .map_err(|error| format!("采集 {} PNG 失败:{error}", viewport.file_stem()))?; - if !screenshot.starts_with(b"\x89PNG\r\n\x1a\n") { - return Err(format!("{} 截图不是有效 PNG", viewport.file_stem())); - } - let screenshot_path = input - .evidence_root - .join(format!("{}.png", viewport.file_stem())); - write_atomic(&screenshot_path, &screenshot)?; - tokio::task::yield_now().await; - - let capture = tasks.stop().await?; - let _ = page.close().await; - if !capture.infrastructure_errors.is_empty() { - return Err(format!( - "浏览器安全拦截失败:{}", - capture.infrastructure_errors.join(";") - )); - } - - let expected_text = input - .expected_text - .iter() - .enumerate() - .map(|(index, text)| BrowserExpectedTextMatch { - text: text.clone(), - found: snapshot - .expected_text_matches - .get(index) - .copied() - .unwrap_or(false), - }) - .collect::>(); - let mut diagnostics = Vec::new(); - if snapshot.ready_state != "complete" { - diagnostics.push(format!("document.readyState={}", snapshot.ready_state)); - } - let missing_text = expected_text - .iter() - .filter(|item| !item.found) - .map(|item| item.text.as_str()) - .collect::>(); - if !missing_text.is_empty() { - diagnostics.push(format!("缺少可见文本:{}", missing_text.join("、"))); - } - if input.fail_on_console_error && !capture.console_errors.is_empty() { - diagnostics.push(format!("console error {} 条", capture.console_errors.len())); - } - if !capture.exceptions.is_empty() { - diagnostics.push(format!("未捕获异常 {} 条", capture.exceptions.len())); - } - let fatal_request_count = capture - .failed_requests - .iter() - .filter(|request| request.fatal) - .count(); - if fatal_request_count > 0 { - diagnostics.push(format!("失败请求 {} 条", fatal_request_count)); - } - if !same_preview_origin(&snapshot.final_url, preview_url) { - diagnostics.push("页面最终 URL 已离开当前预览 origin".to_string()); - } - let canvases = snapshot - .canvases - .into_iter() - .map(BrowserCanvasSnapshot::into_evidence) - .collect::>(); - if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) { - diagnostics.push(diagnostic.to_string()); - } - - Ok(BrowserViewportValidationOutcome { - result: BrowserViewportValidationResult { - viewport, - width, - height, - final_url: sanitize_url(&snapshot.final_url), - title: truncate_chars(&snapshot.title, 512), - ready_state: snapshot.ready_state, - visible_text_summary: snapshot.visible_text_summary, - visible_text_character_count: snapshot.visible_text_character_count, - dom_character_count: snapshot.dom_character_count, - expected_text, - console_errors: capture.console_errors, - console_warnings: capture.console_warnings, - exceptions: capture.exceptions, - failed_requests: capture.failed_requests, - canvases, - blocked_popup_count: snapshot.blocked_popup_count, - blocked_dialog_count: capture.blocked_dialog_count, - blocked_download_count: snapshot.blocked_download_count, - blocked_permission_count: snapshot.blocked_permission_count, - blocked_service_worker_count: snapshot.blocked_service_worker_count, - screenshot_path, - passed: diagnostics.is_empty(), - diagnostics, - }, - playtest, - }) -} - -fn preview_fetch_enable_params() -> FetchEnableParams { - FetchEnableParams::builder() - .pattern( - RequestPattern::builder() - .url_pattern("*") - .request_stage(RequestStage::Request) - .build(), - ) - .build() -} - -async fn start_capture_tasks(page: &Page, preview_url: &Url) -> Result { - let mut paused = page - .event_listener::() - .await - .map_err(|error| format!("监听请求拦截失败:{error}"))?; - page.execute(preview_fetch_enable_params()) - .await - .map_err(|error| format!("启用请求阶段安全拦截失败:{error}"))?; - let mut request_events = page - .event_listener::() - .await - .map_err(|error| format!("监听网络请求失败:{error}"))?; - let mut loading_failed = page - .event_listener::() - .await - .map_err(|error| format!("监听失败请求失败:{error}"))?; - let mut responses = page - .event_listener::() - .await - .map_err(|error| format!("监听 HTTP 响应失败:{error}"))?; - let mut websockets = page - .event_listener::() - .await - .map_err(|error| format!("监听 WebSocket 失败:{error}"))?; - let mut websocket_handshakes = page - .event_listener::() - .await - .map_err(|error| format!("监听 WebSocket 握手失败:{error}"))?; - let mut websocket_errors = page - .event_listener::() - .await - .map_err(|error| format!("监听 WebSocket 错误失败:{error}"))?; - let mut console = page - .event_listener::() - .await - .map_err(|error| format!("监听 console 失败:{error}"))?; - let mut exceptions = page - .event_listener::() - .await - .map_err(|error| format!("监听异常失败:{error}"))?; - let mut dialogs = page - .event_listener::() - .await - .map_err(|error| format!("监听弹窗失败:{error}"))?; - - let state = Arc::new(Mutex::new(CaptureState::default())); - let mut handles = Vec::new(); - - let task_page = page.clone(); - let task_state = state.clone(); - let origin = preview_url.clone(); - handles.push(tokio::spawn(async move { - while let Some(event) = paused.next().await { - let decision = preview_request_decision( - &event.request.url, - &event.resource_type, - event.redirected_request_id.is_some(), - &origin, - ); - if let PreviewRequestDecision::Block(reason) = decision { - if let Ok(mut capture) = task_state.lock() { - capture.push_failed_request(BrowserFailedRequest { - url: sanitize_url(&event.request.url), - method: event.request.method.clone(), - resource_type: event.resource_type.as_ref().to_string(), - error_text: reason.message().to_string(), - status_code: None, - canceled: true, - blocked_by_policy: true, - fatal: true, - }); - } - if let Err(error) = task_page - .execute(FailRequestParams::new( - event.request_id.clone(), - ErrorReason::BlockedByClient, - )) - .await - { - record_capture_error(&task_state, format!("阻止跨 origin 请求失败:{error}")); - break; - } - } else if let Err(error) = task_page - .execute(ContinueRequestParams::new(event.request_id.clone())) - .await - { - record_capture_error(&task_state, format!("放行同 origin 请求失败:{error}")); - break; - } - } - })); - - let task_state = state.clone(); - handles.push(tokio::spawn(async move { - while let Some(event) = request_events.next().await { - if let Ok(mut capture) = task_state.lock() { - if capture.requests.len() < MAX_TRACKED_REQUESTS { - capture.requests.insert( - event.request_id.inner().clone(), - RequestInfo { - url: event.request.url.clone(), - method: event.request.method.clone(), - resource_type: event - .r#type - .as_ref() - .map(|value| value.as_ref().to_string()) - .unwrap_or_else(|| "Other".to_string()), - }, - ); - } - } - } - })); - - let task_state = state.clone(); - handles.push(tokio::spawn(async move { - while let Some(event) = loading_failed.next().await { - if let Ok(mut capture) = task_state.lock() { - let info = capture.requests.get(event.request_id.inner()).cloned(); - let canceled = event.canceled.unwrap_or(false); - let error_text = truncate_chars(&event.error_text, MAX_EVENT_TEXT_CHARS); - let fatal = !(canceled && error_text.contains("ERR_ABORTED")); - capture.push_failed_request(BrowserFailedRequest { - url: sanitize_url(info.as_ref().map(|value| value.url.as_str()).unwrap_or("")), - method: info - .as_ref() - .map(|value| value.method.clone()) - .unwrap_or_else(|| "GET".to_string()), - resource_type: info - .as_ref() - .map(|value| value.resource_type.clone()) - .unwrap_or_else(|| event.r#type.as_ref().to_string()), - error_text, - status_code: None, - canceled, - blocked_by_policy: false, - fatal, - }); - } - } - })); - - let task_state = state.clone(); - handles.push(tokio::spawn(async move { - while let Some(event) = responses.next().await { - if event.response.status < 400 { - continue; - } - if let Ok(mut capture) = task_state.lock() { - let info = capture.requests.get(event.request_id.inner()).cloned(); - let status = u16::try_from(event.response.status).unwrap_or(u16::MAX); - let url = event.response.url.clone(); - let favicon_404 = status == 404 - && Url::parse(&url) - .ok() - .map(|value| value.path() == "/favicon.ico") - .unwrap_or(false); - capture.push_failed_request(BrowserFailedRequest { - url: sanitize_url(&url), - method: info - .as_ref() - .map(|value| value.method.clone()) - .unwrap_or_else(|| "GET".to_string()), - resource_type: event.r#type.as_ref().to_string(), - error_text: format!("HTTP {status}"), - status_code: Some(status), - canceled: false, - blocked_by_policy: false, - fatal: !favicon_404, - }); - } - } - })); - - let task_state = state.clone(); - let origin = preview_url.clone(); - handles.push(tokio::spawn(async move { - loop { - tokio::select! { - biased; - event = websockets.next() => { - let Some(event) = event else { - break; - }; - if let Ok(mut capture) = task_state.lock() { - capture.requests.insert( - event.request_id.inner().clone(), - RequestInfo { - url: event.url.clone(), - method: "GET".to_string(), - resource_type: "WebSocket".to_string(), - }, - ); - if let PreviewRequestDecision::Block(reason) = preview_request_decision( - &event.url, - &ResourceType::WebSocket, - false, - &origin, - ) { - capture.push_failed_request(BrowserFailedRequest { - url: sanitize_url(&event.url), - method: "GET".to_string(), - resource_type: "WebSocket".to_string(), - error_text: reason.message().to_string(), - status_code: None, - canceled: true, - blocked_by_policy: true, - fatal: true, - }); - } - } - } - event = websocket_handshakes.next() => { - let Some(event) = event else { - break; - }; - if let Ok(mut capture) = task_state.lock() { - match capture.requests.get(event.request_id.inner()).cloned() { - Some(info) - if matches!( - preview_request_decision( - &info.url, - &ResourceType::WebSocket, - false, - &origin, - ), - PreviewRequestDecision::Block(_) - ) => - { - capture.push_infrastructure_error( - "跨 origin WebSocket 已进入握手阶段".to_string(), - ); - } - None => capture.push_infrastructure_error( - "无法核对 WebSocket 握手 origin".to_string(), - ), - _ => {} - } - } - } - } - } - })); - - let task_state = state.clone(); - handles.push(tokio::spawn(async move { - while let Some(event) = websocket_errors.next().await { - if let Ok(mut capture) = task_state.lock() { - let info = capture.requests.get(event.request_id.inner()).cloned(); - capture.push_failed_request(BrowserFailedRequest { - url: sanitize_url(info.as_ref().map(|value| value.url.as_str()).unwrap_or("")), - method: "GET".to_string(), - resource_type: "WebSocket".to_string(), - error_text: truncate_chars(&event.error_message, MAX_EVENT_TEXT_CHARS), - status_code: None, - canceled: false, - blocked_by_policy: false, - fatal: true, - }); - } - } - })); - - let task_state = state.clone(); - handles.push(tokio::spawn(async move { - while let Some(event) = console.next().await { - let level = match event.r#type { - ConsoleApiCalledType::Error | ConsoleApiCalledType::Assert => "error", - ConsoleApiCalledType::Warning => "warning", - _ => continue, - }; - let location = event - .stack_trace - .as_ref() - .and_then(|trace| trace.call_frames.first()); - let message = BrowserConsoleMessage { - level: level.to_string(), - text: truncate_chars( - &event - .args - .iter() - .map(remote_object_text) - .collect::>() - .join(" "), - MAX_EVENT_TEXT_CHARS, - ), - source_url: location.map(|frame| sanitize_url(&frame.url)), - line_number: location.map(|frame| nonnegative_u32(frame.line_number)), - column_number: location.map(|frame| nonnegative_u32(frame.column_number)), - }; - if let Ok(mut capture) = task_state.lock() { - let target = if level == "error" { - &mut capture.console_errors - } else { - &mut capture.console_warnings - }; - if target.len() < MAX_CAPTURED_EVENTS { - target.push(message); - } - } - } - })); - - let task_state = state.clone(); - handles.push(tokio::spawn(async move { - while let Some(event) = exceptions.next().await { - let details = &event.exception_details; - let text = details - .exception - .as_ref() - .and_then(|value| value.description.as_deref()) - .unwrap_or(&details.text); - if let Ok(mut capture) = task_state.lock() { - if capture.exceptions.len() < MAX_CAPTURED_EVENTS { - capture.exceptions.push(BrowserException { - text: truncate_chars(text, MAX_EVENT_TEXT_CHARS), - source_url: details.url.as_deref().map(sanitize_url), - line_number: nonnegative_u32(details.line_number), - column_number: nonnegative_u32(details.column_number), - }); - } - } - } - })); - - let task_page = page.clone(); - let task_state = state.clone(); - handles.push(tokio::spawn(async move { - while dialogs.next().await.is_some() { - if let Ok(mut capture) = task_state.lock() { - capture.blocked_dialog_count = capture.blocked_dialog_count.saturating_add(1); - } - if let Err(error) = task_page - .execute(HandleJavaScriptDialogParams::new(false)) - .await - { - record_capture_error(&task_state, format!("关闭 JavaScript 弹窗失败:{error}")); - break; - } - } - })); - - Ok(CaptureTasks { state, handles }) -} - -fn record_capture_error(state: &Arc>, error: String) { - if let Ok(mut capture) = state.lock() { - capture.push_infrastructure_error(error); - } -} - -fn validate_input(input: &BrowserValidationInput) -> Result { - if input.url.chars().count() > MAX_URL_CHARS { - return Err("预览 URL 过长".to_string()); - } - let url = Url::parse(input.url.trim()).map_err(|error| format!("预览 URL 无效:{error}"))?; - if url.scheme() != "http" - || url.host() != Some(Host::Ipv4(Ipv4Addr::LOCALHOST)) - || url.port().is_none() - || url.port() == Some(0) - || !url.username().is_empty() - || url.password().is_some() - || url.fragment().is_some() - { - return Err("只允许带显式端口的 http://127.0.0.1 预览 URL".to_string()); - } - validate_fixed_viewports(&input.viewports)?; - if input.settle_ms > MAX_SETTLE_MS { - return Err(format!("settleMs 不能超过 {MAX_SETTLE_MS}")); - } - if input.expected_text.len() > MAX_EXPECTED_TEXT_ITEMS { - return Err(format!( - "expectedText 不能超过 {MAX_EXPECTED_TEXT_ITEMS} 项" - )); - } - for text in &input.expected_text { - let length = text.chars().count(); - if text.trim().is_empty() || length > MAX_EXPECTED_TEXT_CHARS { - return Err(format!( - "expectedText 每项必须非空且不超过 {MAX_EXPECTED_TEXT_CHARS} 字符" - )); - } - } - validate_evidence_path(&input.evidence_root)?; - Ok(url) -} - -fn validate_evidence_path(path: &Path) -> Result<(), String> { - if !path.is_absolute() || path.parent().is_none() { - return Err("evidenceRoot 必须是非根目录的绝对路径".to_string()); - } - if path - .components() - .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) - { - return Err("evidenceRoot 不能包含 . 或 ..".to_string()); - } - Ok(()) -} - -fn prepare_evidence_root(path: &Path) -> Result<(), String> { - validate_evidence_path(path)?; - if let Ok(metadata) = fs::symlink_metadata(path) { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string()); - } - } else { - fs::create_dir_all(path) - .map_err(|error| format!("创建浏览器证据目录失败:{}: {error}", path.display()))?; - } - let metadata = fs::symlink_metadata(path) - .map_err(|error| format!("读取浏览器证据目录失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string()); - } - Ok(()) -} - -fn preview_proxy_bypass_list(origin: &Url) -> String { - let (Some(host), Some(port)) = (origin.host_str(), origin.port()) else { - return "<-loopback>".to_string(); - }; - format!("<-loopback>;http://{host}:{port};ws://{host}:{port}") -} - -fn preview_request_decision( - raw: &str, - resource_type: &ResourceType, - redirected: bool, - origin: &Url, -) -> PreviewRequestDecision { - let allowed = if resource_type == &ResourceType::WebSocket { - websocket_url_allowed(raw, origin) - } else { - request_url_allowed(raw, origin) - }; - if allowed { - PreviewRequestDecision::Allow - } else if resource_type == &ResourceType::WebSocket { - PreviewRequestDecision::Block(PreviewRequestBlockReason::WebSocketBeforeHandshake) - } else if redirected { - PreviewRequestDecision::Block(PreviewRequestBlockReason::RedirectTarget) - } else { - PreviewRequestDecision::Block(PreviewRequestBlockReason::CrossOrigin) - } -} - -fn request_url_allowed(raw: &str, origin: &Url) -> bool { - if raw == "about:blank" || raw.starts_with("data:") { - return true; - } - if let Some(inner) = raw.strip_prefix("blob:") { - return Url::parse(inner) - .ok() - .map(|url| same_origin_url(&url, origin)) - .unwrap_or(false); - } - let Ok(url) = Url::parse(raw) else { - return false; - }; - match url.scheme() { - "http" => same_origin_url(&url, origin), - _ => false, - } -} - -fn websocket_url_allowed(raw: &str, origin: &Url) -> bool { - let Ok(url) = Url::parse(raw) else { - return false; - }; - origin.scheme() == "http" - && url.scheme() == "ws" - && url.host() == origin.host() - && url.port_or_known_default() == origin.port_or_known_default() - && url.username().is_empty() - && url.password().is_none() - && url.fragment().is_none() -} - -fn same_preview_origin(raw: &str, origin: &Url) -> bool { - Url::parse(raw) - .ok() - .map(|url| same_origin_url(&url, origin)) - .unwrap_or(false) -} - -fn same_origin_url(left: &Url, right: &Url) -> bool { - left.scheme() == right.scheme() - && left.host() == right.host() - && left.port_or_known_default() == right.port_or_known_default() -} - -const READ_PLAYABLE_GAME_STATE_SCRIPT: &str = r#"(() => { - const byId = document.getElementById('playable-web-game-state'); - const exactScripts = document.querySelectorAll('script#playable-web-game-state'); - if (!byId) { - return { status: 'missing', contentLength: 0, content: null }; - } - if (!(byId instanceof HTMLScriptElement) || exactScripts.length !== 1 || exactScripts[0] !== byId) { - return { status: 'invalid-element', contentLength: 0, content: null }; - } - if (byId.getAttribute('type') !== 'application/json') { - return { status: 'invalid-type', contentLength: 0, content: null }; - } - const content = String(byId.textContent || ''); - if (content.length > 131072) { - return { status: 'too-large', contentLength: content.length, content: null }; - } - return { status: 'ok', contentLength: content.length, content }; -})()"#; - -const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() { - const isHtmlElement = this instanceof HTMLElement; - if (!isHtmlElement) { - return JSON.stringify({ isHtmlElement: false, visible: false, disabled: true }); - } - - let stylesVisible = true; - let pointerBlocked = false; - for (let current = this; current instanceof HTMLElement; current = current.parentElement) { - const style = getComputedStyle(current); - const opacity = Number.parseFloat(style.opacity); - stylesVisible &&= !current.hidden - && style.display !== 'none' - && style.visibility !== 'hidden' - && style.visibility !== 'collapse' - && (Number.isNaN(opacity) || opacity > 0); - pointerBlocked ||= style.pointerEvents === 'none'; - } - - const rect = this.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const centerInViewport = centerX >= 0 - && centerY >= 0 - && centerX < window.innerWidth - && centerY < window.innerHeight; - const hit = centerInViewport ? document.elementFromPoint(centerX, centerY) : null; - const visible = this.isConnected - && stylesVisible - && rect.width > 0 - && rect.height > 0 - && this.getClientRects().length > 0 - && hit !== null - && (hit === this || this.contains(hit)); - const ariaDisabled = String(this.getAttribute('aria-disabled') || '').toLowerCase() === 'true'; - const disabled = this.matches(':disabled') - || this.hasAttribute('disabled') - || ariaDisabled - || pointerBlocked - || this.closest('[inert]') !== null; - return JSON.stringify({ isHtmlElement, visible, disabled }); -}"#; - -const PLAYTEST_START_SELECTOR: &str = r#"[data-playtest-id="start"]"#; -const PLAYTEST_RESTART_SELECTOR: &str = r#"[data-playtest-id="restart"]"#; -const PLAYTEST_DEFENDER_OPTION_SELECTOR: &str = r#"[data-playtest-id="defender-option"]"#; -const PLAYTEST_LANE_CELL_SELECTOR: &str = r#"[data-playtest-id="lane-cell"]"#; -const PLAYTEST_SPEED_UP_SELECTOR: &str = r#"[data-playtest-id="speed-up"]"#; -const PLAYTEST_NEXT_LEVEL_SELECTOR: &str = r#"[data-playtest-id="next-level"]"#; - -fn parse_playable_web_game_state( - content: &str, - scenario: BrowserPlaytestScenario, -) -> Result { - if content.chars().count() > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { - return Err("固定试玩状态 JSON 超过大小上限".to_string()); - } - let value = serde_json::from_str::(content).map_err(|error| { - format!( - "固定试玩状态 JSON 无效(第 {} 行,第 {} 列)", - error.line(), - error.column() - ) - })?; - let object = value - .as_object() - .ok_or_else(|| "固定试玩状态必须是 JSON object".to_string())?; - if object - .get("schemaVersion") - .and_then(serde_json::Value::as_str) - != Some(PLAYABLE_GAME_STATE_SCHEMA_VERSION) - { - return Err("固定试玩状态 schemaVersion 无效".to_string()); - } - let sequence = required_playable_u64(object, "sequence")?; - let level = required_playable_u64(object, "level")?; - let phase = match object.get("phase").and_then(serde_json::Value::as_str) { - Some("ready") => BrowserPlaytestPhase::Ready, - Some("playing") => BrowserPlaytestPhase::Playing, - Some("won") => BrowserPlaytestPhase::Won, - Some("lost") => BrowserPlaytestPhase::Lost, - _ => return Err("固定试玩状态 phase 无效".to_string()), - }; - - let (selected_defender_id, defender_count, enemies) = match scenario { - BrowserPlaytestScenario::GenericV1 => (None, None, None), - BrowserPlaytestScenario::LaneDefenseV1 => parse_lane_defense_playable_state(object)?, - }; - - Ok(PlayableWebGameState { - sequence, - phase, - level, - selected_defender_id, - defender_count, - enemies, - }) -} - -fn required_playable_u64( - object: &serde_json::Map, - field: &str, -) -> Result { - object - .get(field) - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| format!("固定试玩状态 {field} 必须是 u64")) -} - -fn parse_lane_defense_playable_state( - object: &serde_json::Map, -) -> Result< - ( - Option, - Option, - Option>, - ), - String, -> { - let selected_defender_id = match object.get("selectedDefenderId") { - Some(serde_json::Value::Null) => None, - Some(serde_json::Value::String(value)) - if !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS => - { - Some(value.clone()) - } - Some(_) => { - return Err( - "lane-defense 状态 selectedDefenderId 必须是 null 或非空字符串".to_string(), - ); - } - None => return Err("lane-defense 状态缺少 selectedDefenderId".to_string()), - }; - let defenders = object - .get("defenders") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "lane-defense 状态 defenders 必须是数组".to_string())?; - if defenders.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS { - return Err("lane-defense 状态 defenders 超过数量上限".to_string()); - } - let enemy_values = object - .get("enemies") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "lane-defense 状态 enemies 必须是数组".to_string())?; - if enemy_values.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS { - return Err("lane-defense 状态 enemies 超过数量上限".to_string()); - } - let mut enemy_ids = HashSet::with_capacity(enemy_values.len()); - let mut enemies = Vec::with_capacity(enemy_values.len()); - for enemy_value in enemy_values { - let enemy = enemy_value - .as_object() - .ok_or_else(|| "lane-defense 状态 enemy 必须是 object".to_string())?; - let id = enemy - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| { - !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS - }) - .ok_or_else(|| "lane-defense 状态 enemy.id 必须是有界非空字符串".to_string())?; - if !enemy_ids.insert(id.to_string()) { - return Err("lane-defense 状态 enemy.id 不能重复".to_string()); - } - validate_lane_value( - enemy - .get("lane") - .ok_or_else(|| "lane-defense 状态 enemy 缺少 lane".to_string())?, - )?; - let position = required_playable_finite_number(enemy, "position")?; - let health = required_playable_finite_number(enemy, "health")?; - let max_health = required_playable_finite_number(enemy, "maxHealth")?; - if health < 0.0 || max_health <= 0.0 || health > max_health { - return Err("lane-defense 状态 enemy health/maxHealth 边界无效".to_string()); - } - enemies.push(PlayableEnemyState { - id: id.to_string(), - position, - health, - }); - } - Ok((selected_defender_id, Some(defenders.len()), Some(enemies))) -} - -fn validate_lane_value(value: &serde_json::Value) -> Result<(), String> { - match value { - serde_json::Value::String(value) - if !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS => - { - Ok(()) - } - serde_json::Value::Number(value) if value.as_u64().is_some() => Ok(()), - _ => Err("lane-defense 状态 enemy.lane 必须是 u64 或有界非空字符串".to_string()), - } -} - -fn required_playable_finite_number( - object: &serde_json::Map, - field: &str, -) -> Result { - let value = object - .get(field) - .and_then(serde_json::Value::as_f64) - .ok_or_else(|| format!("lane-defense 状态 enemy.{field} 必须是数值"))?; - if !value.is_finite() { - return Err(format!("lane-defense 状态 enemy.{field} 必须是有限数值")); - } - Ok(value) -} - -async fn run_desktop_playtest( - page: &Page, - scenario: BrowserPlaytestScenario, -) -> BrowserPlaytestResult { - let mut result = BrowserPlaytestResult::pending(scenario); - let deadline = Instant::now() + PLAYTEST_TOTAL_TIMEOUT; - let execution = tokio::time::timeout( - PLAYTEST_TOTAL_TIMEOUT, - execute_desktop_playtest(page, scenario, deadline, &mut result), - ) - .await; - match execution { - Ok(Ok(())) => {} - Ok(Err(error)) => result.push_diagnostic(error), - Err(_) => result.push_diagnostic("固定试玩超过总时间上限"), - } - result.finish() -} - -async fn execute_desktop_playtest( - page: &Page, - scenario: BrowserPlaytestScenario, - deadline: Instant, - result: &mut BrowserPlaytestResult, -) -> Result<(), String> { - let initial = read_playable_web_game_state(page, scenario, deadline).await?; - result.record_initial_state(&initial); - result.set_assertion("state-surface-valid", true); - match scenario { - BrowserPlaytestScenario::GenericV1 => { - execute_generic_playtest(page, deadline, result, initial).await - } - BrowserPlaytestScenario::LaneDefenseV1 => { - execute_lane_defense_playtest(page, deadline, result, initial).await - } - } -} - -async fn execute_generic_playtest( - page: &Page, - deadline: Instant, - result: &mut BrowserPlaytestResult, - initial: PlayableWebGameState, -) -> Result<(), String> { - click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?; - result.set_assertion("start-control-clicked", true); - let started = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::GenericV1, - deadline, - initial.sequence, - "start", - |state| { - matches!( - state.phase, - BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won - ) - }, - ) - .await?; - if let Some(state) = started.last_state.as_ref() { - result.record_final_state(state); - } - let start_sequence_advanced = started - .last_state - .as_ref() - .map(|state| state.sequence > initial.sequence) - .unwrap_or(false); - let start_phase_valid = started - .last_state - .as_ref() - .map(|state| { - matches!( - state.phase, - BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won - ) - }) - .unwrap_or(false); - result.set_assertion("start-sequence-advanced", start_sequence_advanced); - result.set_assertion("start-phase-playing-or-won", start_phase_valid); - if !started.matched { - return Err("generic-v1 start 后状态未在总时限内推进".to_string()); - } - let started_state = started - .last_state - .ok_or_else(|| "generic-v1 start 后未读取到状态".to_string())?; - - click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; - result.set_assertion("restart-control-clicked", true); - let restarted = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::GenericV1, - deadline, - started_state.sequence, - "restart", - |state| { - matches!( - state.phase, - BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing - ) - }, - ) - .await?; - if let Some(state) = restarted.last_state.as_ref() { - result.record_final_state(state); - } - let restart_sequence_advanced = restarted - .last_state - .as_ref() - .map(|state| state.sequence > started_state.sequence) - .unwrap_or(false); - let restart_phase_valid = restarted - .last_state - .as_ref() - .map(|state| { - matches!( - state.phase, - BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing - ) - }) - .unwrap_or(false); - result.set_assertion("restart-sequence-advanced", restart_sequence_advanced); - result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid); - if !restarted.matched { - return Err("generic-v1 restart 后状态未在总时限内推进".to_string()); - } - Ok(()) -} - -async fn execute_lane_defense_playtest( - page: &Page, - deadline: Instant, - result: &mut BrowserPlaytestResult, - initial: PlayableWebGameState, -) -> Result<(), String> { - let initial_phase_ready = initial.phase == BrowserPlaytestPhase::Ready; - let level_positive = initial.level > 0; - result.set_assertion("initial-phase-ready", initial_phase_ready); - result.set_assertion("level-positive", level_positive); - if !initial_phase_ready { - return Err("lane-defense-v1 初始状态必须为 ready".to_string()); - } - if !level_positive { - return Err("lane-defense-v1 初始 level 必须大于 0".to_string()); - } - - click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?; - result.set_assertion("start-control-visible", true); - result.set_assertion("start-control-enabled", true); - result.set_assertion("start-control-clicked", true); - let started = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::LaneDefenseV1, - deadline, - initial.sequence, - "start", - |state| state.phase == BrowserPlaytestPhase::Playing, - ) - .await?; - if let Some(state) = started.last_state.as_ref() { - result.record_final_state(state); - } - let start_sequence_advanced = started - .last_state - .as_ref() - .map(|state| state.sequence > initial.sequence) - .unwrap_or(false); - result.set_assertion("start-sequence-advanced", start_sequence_advanced); - result.set_assertion("start-phase-playing", started.matched); - if !started.matched { - return Err("lane-defense-v1 start 后 sequence 未推进或未进入 playing".to_string()); - } - let started_state = started - .last_state - .ok_or_else(|| "lane-defense-v1 start 后未读取到状态".to_string())?; - - click_playtest_control( - page, - PLAYTEST_DEFENDER_OPTION_SELECTOR, - "defender-option", - deadline, - ) - .await?; - result.set_assertion("defender-option-control-visible", true); - result.set_assertion("defender-option-control-enabled", true); - result.set_assertion("defender-option-control-clicked", true); - let selected = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::LaneDefenseV1, - deadline, - started_state.sequence, - "defender-option", - |state| state.selected_defender_id.is_some(), - ) - .await?; - if let Some(state) = selected.last_state.as_ref() { - result.record_final_state(state); - } - let defender_selection_sequence_advanced = selected - .last_state - .as_ref() - .map(|state| state.sequence > started_state.sequence) - .unwrap_or(false); - result.set_assertion( - "defender-selection-sequence-advanced", - defender_selection_sequence_advanced, - ); - result.set_assertion("defender-selection-recorded", selected.matched); - if !selected.matched { - return Err( - "lane-defense-v1 defender-option 后 sequence 未推进或未记录选择状态".to_string(), - ); - } - let selected_state = selected - .last_state - .ok_or_else(|| "lane-defense-v1 选择后未读取到状态".to_string())?; - let defender_count_before_placement = selected_state - .defender_count - .ok_or_else(|| "lane-defense-v1 defenders 状态缺失".to_string())?; - - click_playtest_control(page, PLAYTEST_LANE_CELL_SELECTOR, "lane-cell", deadline).await?; - result.set_assertion("lane-cell-control-visible", true); - result.set_assertion("lane-cell-control-enabled", true); - result.set_assertion("lane-cell-control-clicked", true); - let placed = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::LaneDefenseV1, - deadline, - selected_state.sequence, - "lane-cell", - |state| { - state - .defender_count - .map(|count| count > defender_count_before_placement) - .unwrap_or(false) - }, - ) - .await?; - if let Some(state) = placed.last_state.as_ref() { - result.record_final_state(state); - } - let defender_placement_sequence_advanced = placed - .last_state - .as_ref() - .map(|state| state.sequence > selected_state.sequence) - .unwrap_or(false); - result.set_assertion( - "defender-placement-sequence-advanced", - defender_placement_sequence_advanced, - ); - result.set_assertion("defender-count-increased", placed.matched); - if !placed.matched { - return Err( - "lane-defense-v1 lane-cell 后 sequence 未推进或 defender 数量未增加".to_string(), - ); - } - let mut combat_state = placed - .last_state - .ok_or_else(|| "lane-defense-v1 放置后未读取到状态".to_string())?; - let mut enemies_present = combat_state - .enemies - .as_ref() - .map(|enemies| !enemies.is_empty()) - .unwrap_or(false); - if !enemies_present { - let enemies_ready = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::LaneDefenseV1, - deadline, - combat_state.sequence, - "enemy-spawn", - |state| { - state - .enemies - .as_ref() - .map(|enemies| !enemies.is_empty()) - .unwrap_or(false) - }, - ) - .await?; - if let Some(state) = enemies_ready.last_state.as_ref() { - result.record_final_state(state); - } - enemies_present = enemies_ready.matched; - if let Some(state) = enemies_ready.last_state { - combat_state = state; - } - } - result.set_assertion("enemies-present-after-placement", enemies_present); - if !enemies_present { - return Err("lane-defense-v1 放置后没有可观察 enemy".to_string()); - } - - click_playtest_control(page, PLAYTEST_SPEED_UP_SELECTOR, "speed-up", deadline).await?; - result.set_assertion("speed-up-control-visible", true); - result.set_assertion("speed-up-control-enabled", true); - result.set_assertion("speed-up-control-clicked", true); - let battle_baseline_sequence = combat_state.sequence; - let mut battle_progress = LaneBattleProgress::new(combat_state); - let completed = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::LaneDefenseV1, - deadline, - battle_baseline_sequence, - "speed-up/battle", - |state| { - battle_progress.observe(state); - battle_progress.completed(state) - }, - ) - .await?; - if let Some(state) = completed.last_state.as_ref() { - result.record_final_state(state); - } - let won = completed - .last_state - .as_ref() - .map(|state| state.phase == BrowserPlaytestPhase::Won) - .unwrap_or(false); - result.set_assertion( - "battle-sequence-advanced", - battle_progress.sequence_advanced, - ); - result.set_assertion( - "battle-sequence-monotonic", - battle_progress.sequence_monotonic, - ); - result.set_assertion( - "enemy-position-changed", - battle_progress.enemy_position_changed, - ); - result.set_assertion( - "enemy-health-decreased", - battle_progress.enemy_health_decreased, - ); - result.set_assertion("phase-won", won); - if !completed.matched { - return Err("lane-defense-v1 未在总时限内观察到战斗推进并获胜".to_string()); - } - let won_state = completed - .last_state - .ok_or_else(|| "lane-defense-v1 获胜后未读取到状态".to_string())?; - - click_playtest_control(page, PLAYTEST_NEXT_LEVEL_SELECTOR, "next-level", deadline).await?; - result.set_assertion("next-level-control-visible", true); - result.set_assertion("next-level-control-enabled", true); - result.set_assertion("next-level-control-clicked", true); - let next_level = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::LaneDefenseV1, - deadline, - won_state.sequence, - "next-level", - |state| state.level > won_state.level, - ) - .await?; - if let Some(state) = next_level.last_state.as_ref() { - result.record_final_state(state); - } - let next_level_sequence_advanced = next_level - .last_state - .as_ref() - .map(|state| state.sequence > won_state.sequence) - .unwrap_or(false); - result.set_assertion("next-level-sequence-advanced", next_level_sequence_advanced); - result.set_assertion("level-increased", next_level.matched); - if !next_level.matched { - return Err("lane-defense-v1 next-level 后 sequence 未推进或 level 未增加".to_string()); - } - let next_level_state = next_level - .last_state - .ok_or_else(|| "lane-defense-v1 next-level 后未读取到状态".to_string())?; - - click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; - result.set_assertion("restart-control-visible", true); - result.set_assertion("restart-control-enabled", true); - result.set_assertion("restart-control-clicked", true); - let restarted = poll_playable_web_game_state( - page, - BrowserPlaytestScenario::LaneDefenseV1, - deadline, - next_level_state.sequence, - "restart", - |state| { - matches!( - state.phase, - BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing - ) - }, - ) - .await?; - if let Some(state) = restarted.last_state.as_ref() { - result.record_final_state(state); - } - let restart_sequence_advanced = restarted - .last_state - .as_ref() - .map(|state| state.sequence > next_level_state.sequence) - .unwrap_or(false); - let restart_phase_valid = restarted - .last_state - .as_ref() - .map(|state| { - matches!( - state.phase, - BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing - ) - }) - .unwrap_or(false); - result.set_assertion("restart-sequence-advanced", restart_sequence_advanced); - result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid); - if !restarted.matched { - return Err("lane-defense-v1 restart 后状态未在总时限内推进".to_string()); - } - Ok(()) -} - -fn lane_enemy_state_changes( - previous: &PlayableWebGameState, - current: &PlayableWebGameState, -) -> (bool, bool) { - let Some(previous_enemies) = previous.enemies.as_ref() else { - return (false, false); - }; - let Some(current_enemies) = current.enemies.as_ref() else { - return (false, false); - }; - let current_by_id = current_enemies - .iter() - .map(|enemy| (enemy.id.as_str(), enemy)) - .collect::>(); - let mut position_changed = false; - let mut health_decreased = false; - for previous_enemy in previous_enemies { - match current_by_id.get(previous_enemy.id.as_str()) { - Some(enemy) => { - position_changed |= enemy.position != previous_enemy.position; - health_decreased |= enemy.health < previous_enemy.health; - } - None => { - health_decreased |= previous_enemy.health > 0.0; - } - } - } - (position_changed, health_decreased) -} - -async fn read_playable_web_game_state( - page: &Page, - scenario: BrowserPlaytestScenario, - deadline: Instant, -) -> Result { - let remaining = playtest_remaining(deadline)?; - let evaluated = tokio::time::timeout(remaining, page.evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT)) - .await - .map_err(|_| "读取固定试玩状态超时".to_string())? - .map_err(|_| "读取固定试玩状态失败".to_string())?; - let surface = evaluated - .into_value::() - .map_err(|_| "解析固定试玩状态面读取结果失败".to_string())?; - if surface.content_length > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { - return Err("固定试玩状态 JSON 超过大小上限".to_string()); - } - let content = match surface.status.as_str() { - "ok" => surface - .content - .ok_or_else(|| "固定试玩状态面缺少 JSON 正文".to_string())?, - "missing" => return Err("缺少固定试玩状态面".to_string()), - "invalid-element" => return Err("固定试玩状态面元素无效或不唯一".to_string()), - "invalid-type" => { - return Err("固定试玩状态面 type 必须是 application/json".to_string()); - } - "too-large" => return Err("固定试玩状态 JSON 超过大小上限".to_string()), - _ => return Err("固定试玩状态面读取状态无效".to_string()), - }; - parse_playable_web_game_state(&content, scenario) -} - -async fn click_playtest_control( - page: &Page, - selector: &'static str, - action: &'static str, - deadline: Instant, -) -> Result<(), String> { - let remaining = playtest_remaining(deadline)?; - let mut elements = tokio::time::timeout(remaining, page.find_elements(selector)) - .await - .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不存在或查询失败"))?; - if elements.len() != 1 { - return Err(format!( - "固定试玩控件 {action} 必须唯一,实际数量为 {}", - elements.len() - )); - } - let element = elements - .pop() - .ok_or_else(|| format!("固定试玩控件 {action} 不存在"))?; - - let remaining = playtest_remaining(deadline)?; - tokio::time::timeout(remaining, element.scroll_into_view()) - .await - .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 无法滚动到可见区域"))?; - - let remaining = playtest_remaining(deadline)?; - let evaluated = tokio::time::timeout( - remaining, - element.call_js_fn(PROBE_PLAYTEST_CONTROL_SCRIPT, false), - ) - .await - .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态读取失败"))?; - let probe = evaluated - .result - .value - .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果缺失"))?; - let probe = probe - .as_str() - .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; - let probe = serde_json::from_str::(probe) - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; - if !probe.is_html_element { - return Err(format!("固定试玩控件 {action} 必须是 HTMLElement")); - } - if !probe.visible { - return Err(format!("固定试玩控件 {action} 不可见")); - } - if probe.disabled { - return Err(format!("固定试玩控件 {action} 处于 disabled 状态")); - } - - let remaining = playtest_remaining(deadline)?; - tokio::time::timeout(remaining, element.click()) - .await - .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不可点击"))?; - Ok(()) -} - -async fn poll_playable_web_game_state( - page: &Page, - scenario: BrowserPlaytestScenario, - deadline: Instant, - baseline_sequence: u64, - observation: &'static str, - mut predicate: F, -) -> Result -where - F: FnMut(&PlayableWebGameState) -> bool, -{ - let mut last_state = None; - let mut previous_sequence = baseline_sequence; - loop { - if Instant::now() >= deadline { - return Ok(PlaytestPollOutcome { - matched: false, - last_state, - }); - } - let state = read_playable_web_game_state(page, scenario, deadline).await?; - if state.sequence < previous_sequence { - return Err(format!( - "固定试玩 {observation} 观察到 sequence 从 {previous_sequence} 回退到 {}", - state.sequence - )); - } - previous_sequence = state.sequence; - let matched = state.sequence > baseline_sequence && predicate(&state); - last_state = Some(state); - if matched { - return Ok(PlaytestPollOutcome { - matched: true, - last_state, - }); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Ok(PlaytestPollOutcome { - matched: false, - last_state, - }); - } - tokio::time::sleep(std::cmp::min(PLAYTEST_POLL_INTERVAL, remaining)).await; - } -} - -fn playtest_remaining(deadline: Instant) -> Result { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - Err("固定试玩超过总时间上限".to_string()) - } else { - Ok(remaining) - } -} - -fn build_snapshot_script(expected_text: &[String]) -> Result { - let expected = serde_json::to_string(expected_text) - .map_err(|error| format!("序列化 expectedText 失败:{error}"))?; - Ok(format!( - r#"(() => {{ - const expected = {expected}; - const text = String(document.body?.innerText || '').replace(/\s+/g, ' ').trim(); - const security = window.__GENARRATIVE_PREVIEW_VALIDATION__ || {{}}; - const canvases = Array.from(document.querySelectorAll('canvas')).slice(0, 32).map((canvas) => {{ - const rect = canvas.getBoundingClientRect(); - const style = getComputedStyle(canvas); - const visibleWidth = Math.max(0, Math.min(rect.right, innerWidth) - Math.max(rect.left, 0)); - const visibleHeight = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0)); - const visibleArea = style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 - ? 0 : Math.round(visibleWidth * visibleHeight); - let sampleCount = 0; - let nonEmptyPixelCount = 0; - let distinctPixelStateCount = 0; - let probeError = null; - if (canvas.width > 0 && canvas.height > 0 && visibleArea > 0) {{ - try {{ - const probe = document.createElement('canvas'); - probe.width = Math.min(64, canvas.width); - probe.height = Math.min(64, canvas.height); - const context = probe.getContext('2d', {{ willReadFrequently: true }}); - context.drawImage(canvas, 0, 0, probe.width, probe.height); - const pixels = context.getImageData(0, 0, probe.width, probe.height).data; - sampleCount = pixels.length / 4; - let firstPixelState = null; - for (let index = 0; index < pixels.length; index += 4) {{ - if (pixels[index + 3] !== 0) nonEmptyPixelCount += 1; - const pixelState = pixels[index] * 0x1000000 - + pixels[index + 1] * 0x10000 - + pixels[index + 2] * 0x100 - + pixels[index + 3]; - if (firstPixelState === null) {{ - firstPixelState = pixelState; - distinctPixelStateCount = 1; - }} else if (distinctPixelStateCount === 1 && pixelState !== firstPixelState) {{ - distinctPixelStateCount = 2; - }} - }} - }} catch (error) {{ - probeError = String(error).slice(0, 512); - }} - }} - return {{ - width: canvas.width, - height: canvas.height, - cssWidth: rect.width, - cssHeight: rect.height, - visibleArea, - sampleCount, - nonEmptyPixelCount, - distinctPixelStateCount, - probeError - }}; - }}); - return {{ - finalUrl: location.href, - title: document.title, - readyState: document.readyState, - visibleTextSummary: text.slice(0, {MAX_VISIBLE_TEXT_CHARS}), - visibleTextCharacterCount: text.length, - domCharacterCount: document.documentElement?.outerHTML?.length || 0, - expectedTextMatches: expected.map((value) => text.includes(value)), - canvases, - blockedPopupCount: security.blockedPopupCount || 0, - blockedDownloadCount: security.blockedDownloadCount || 0, - blockedPermissionCount: security.blockedPermissionCount || 0, - blockedServiceWorkerCount: security.blockedServiceWorkerCount || 0 - }}; -}})()"# - )) -} - -const RESTRICTION_SCRIPT: &str = r#" -(() => { - const state = { - blockedPopupCount: 0, - blockedDownloadCount: 0, - blockedPermissionCount: 0, - blockedServiceWorkerCount: 0 - }; - Object.defineProperty(window, '__GENARRATIVE_PREVIEW_VALIDATION__', { - value: state, - configurable: false, - enumerable: false, - writable: false - }); - const blockPopup = () => { state.blockedPopupCount += 1; return null; }; - try { Object.defineProperty(window, 'open', { value: blockPopup, configurable: false }); } - catch (_) { window.open = blockPopup; } - document.addEventListener('click', (event) => { - const anchor = event.target?.closest?.('a'); - if (!anchor) return; - if (anchor.hasAttribute('download')) { - state.blockedDownloadCount += 1; - event.preventDefault(); - } - if (anchor.target && anchor.target.toLowerCase() !== '_self') { - state.blockedPopupCount += 1; - event.preventDefault(); - } - }, true); - document.addEventListener('submit', (event) => { - const target = event.target?.target; - if (target && target.toLowerCase() !== '_self') { - state.blockedPopupCount += 1; - event.preventDefault(); - } - }, true); - const denied = () => { - state.blockedPermissionCount += 1; - return Promise.reject(new DOMException('Permission denied during preview validation', 'NotAllowedError')); - }; - if (navigator.mediaDevices) { - try { navigator.mediaDevices.getUserMedia = denied; } catch (_) {} - try { navigator.mediaDevices.getDisplayMedia = denied; } catch (_) {} - } - if (navigator.clipboard) { - try { navigator.clipboard.read = denied; } catch (_) {} - try { navigator.clipboard.readText = denied; } catch (_) {} - try { navigator.clipboard.write = denied; } catch (_) {} - try { navigator.clipboard.writeText = denied; } catch (_) {} - } - if (navigator.geolocation) { - const geolocationDenied = (_success, failure) => { - state.blockedPermissionCount += 1; - if (failure) failure({ code: 1, message: 'Permission denied during preview validation' }); - }; - try { navigator.geolocation.getCurrentPosition = geolocationDenied; } catch (_) {} - try { navigator.geolocation.watchPosition = geolocationDenied; } catch (_) {} - } - if (window.Notification?.requestPermission) { - try { - Notification.requestPermission = () => { - state.blockedPermissionCount += 1; - return Promise.resolve('denied'); - }; - } catch (_) {} - } - if (navigator.serviceWorker) { - try { - const prototype = Object.getPrototypeOf(navigator.serviceWorker); - Object.defineProperty(prototype, 'register', { - value: () => { - state.blockedServiceWorkerCount += 1; - return Promise.reject(new DOMException('Service Worker disabled during preview validation', 'SecurityError')); - }, - configurable: false - }); - } catch (_) {} - } -})(); -"#; - -fn remote_object_text(object: &RemoteObject) -> String { - if let Some(value) = &object.value { - if let Some(value) = value.as_str() { - return value.to_string(); - } - return value.to_string(); - } - object - .description - .clone() - .unwrap_or_else(|| object.r#type.as_ref().to_string()) -} - -fn sanitize_url(raw: &str) -> String { - let Ok(mut url) = Url::parse(raw) else { - return truncate_chars(raw, MAX_URL_CHARS); - }; - url.set_query(None); - url.set_fragment(None); - truncate_chars(url.as_str(), MAX_URL_CHARS) -} - -fn truncate_chars(value: &str, limit: usize) -> String { - value.chars().take(limit).collect() -} - -fn nonnegative_u32(value: i64) -> u32 { - u32::try_from(value).unwrap_or_default() -} - -fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { - let parent = path - .parent() - .ok_or_else(|| format!("证据文件缺少父目录:{}", path.display()))?; - let mut temporary = - NamedTempFile::new_in(parent).map_err(|error| format!("创建证据临时文件失败:{error}"))?; - temporary - .write_all(bytes) - .map_err(|error| format!("写入证据临时文件失败:{error}"))?; - temporary - .as_file() - .sync_all() - .map_err(|error| format!("同步证据临时文件失败:{error}"))?; - temporary - .persist(path) - .map_err(|error| format!("保存证据文件失败:{}: {}", path.display(), error.error))?; - Ok(()) -} - -fn write_json_report(path: &Path, result: &BrowserValidationResult) -> Result<(), String> { - let bytes = serde_json::to_vec_pretty(result) - .map_err(|error| format!("序列化浏览器验证报告失败:{error}"))?; - write_atomic(path, &bytes) -} - -fn unix_time_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} +pub(crate) use playtest::browser_playtest_scenario_fingerprint; #[cfg(test)] -mod tests { - use super::*; - - static PATH_TEST_LOCK: Mutex<()> = Mutex::new(()); - - fn valid_input() -> BrowserValidationInput { - BrowserValidationInput { - url: "http://127.0.0.1:34567/".to_string(), - viewports: vec![ - BrowserValidationViewport::Desktop, - BrowserValidationViewport::Mobile, - ], - expected_text: vec!["开始游戏".to_string()], - settle_ms: DEFAULT_SETTLE_MS, - fail_on_console_error: true, - playtest_scenario: None, - evidence_root: env::temp_dir().join("browser-validation-test-evidence"), - } - } - - fn canvas_snapshot( - visible_area: f64, - sample_count: u32, - non_empty_pixel_count: u32, - distinct_pixel_state_count: u32, - ) -> BrowserCanvasSnapshot { - BrowserCanvasSnapshot { - width: 64, - height: 64, - css_width: 64.0, - css_height: 64.0, - visible_area, - sample_count, - non_empty_pixel_count, - distinct_pixel_state_count, - probe_error: None, - } - } - - fn lane_state( - sequence: u64, - phase: BrowserPlaytestPhase, - enemies: Vec, - ) -> PlayableWebGameState { - PlayableWebGameState { - sequence, - phase, - level: 1, - selected_defender_id: None, - defender_count: Some(1), - enemies: Some(enemies), - } - } - - #[test] - fn validates_loopback_url_and_rejects_external_urls() { - assert!(validate_input(&valid_input()).is_ok()); - for url in [ - "https://127.0.0.1:34567/", - "http://localhost:34567/", - "http://127.0.0.1/", - "http://127.0.0.1:34567/#fragment", - "https://example.com/", - ] { - let mut input = valid_input(); - input.url = url.to_string(); - assert!(validate_input(&input).is_err(), "accepted {url}"); - } - } - - #[test] - fn validates_fixed_viewports_and_input_bounds() { - assert_eq!( - BrowserValidationViewport::Desktop.dimensions(), - (1280, 720, false) - ); - assert_eq!( - BrowserValidationViewport::Mobile.dimensions(), - (390, 844, true) - ); - let mut input = valid_input(); - input.viewports.clear(); - assert!(validate_input(&input).is_err()); - for viewports in [ - vec![BrowserValidationViewport::Desktop], - vec![BrowserValidationViewport::Mobile], - vec![ - BrowserValidationViewport::Desktop, - BrowserValidationViewport::Desktop, - ], - vec![ - BrowserValidationViewport::Mobile, - BrowserValidationViewport::Mobile, - ], - ] { - input.viewports = viewports; - assert!(validate_input(&input).is_err()); - } - input.viewports = vec![ - BrowserValidationViewport::Mobile, - BrowserValidationViewport::Desktop, - ]; - assert!(validate_input(&input).is_ok()); - input = valid_input(); - input.settle_ms = MAX_SETTLE_MS + 1; - assert!(validate_input(&input).is_err()); - input = valid_input(); - input.expected_text = vec![" ".to_string()]; - assert!(validate_input(&input).is_err()); - input = valid_input(); - input.evidence_root = PathBuf::from("relative/evidence"); - assert!(validate_input(&input).is_err()); - } - - #[test] - fn deserialization_requires_exactly_desktop_and_mobile_viewports() { - for viewports in [ - serde_json::json!(["desktop"]), - serde_json::json!(["mobile"]), - serde_json::json!(["desktop", "desktop"]), - serde_json::json!(["mobile", "mobile"]), - serde_json::json!(["desktop", "tablet"]), - serde_json::json!(["desktop", "mobile", "tablet"]), - ] { - let value = serde_json::json!({ - "url": "http://127.0.0.1:34567/", - "viewports": viewports, - "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") - }); - assert!( - serde_json::from_value::(value).is_err(), - "accepted invalid viewports {viewports}" - ); - } - - let input = serde_json::from_value::(serde_json::json!({ - "url": "http://127.0.0.1:34567/", - "viewports": ["mobile", "desktop"], - "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") - })) - .expect("deserialize both fixed viewports"); - assert_eq!( - input.viewports, - vec![ - BrowserValidationViewport::Mobile, - BrowserValidationViewport::Desktop - ] - ); - assert_eq!(input.settle_ms, DEFAULT_SETTLE_MS); - assert!(input.fail_on_console_error); - assert_eq!(input.playtest_scenario, None); - } - - #[test] - fn playtest_input_accepts_only_fixed_scenario_names_and_rejects_custom_controls() { - for scenario in ["generic-v1", "lane-defense-v1"] { - let input = serde_json::from_value::(serde_json::json!({ - "url": "http://127.0.0.1:34567/", - "viewports": ["desktop", "mobile"], - "playtestScenario": scenario, - "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") - })) - .expect("deserialize fixed playtest scenario"); - assert!(input.playtest_scenario.is_some()); - } - - for scenario in [ - serde_json::json!("custom-v1"), - serde_json::json!({"scenario": "generic-v1", "selector": "#custom"}), - serde_json::json!({"scenario": "generic-v1", "script": "alert(1)"}), - serde_json::json!({"scenario": "generic-v1", "url": "https://example.test"}), - serde_json::json!({"scenario": "generic-v1", "headers": {"x-test": "1"}}), - serde_json::json!({"scenario": "generic-v1", "cookie": "session=test"}), - serde_json::json!({"scenario": "generic-v1", "actions": ["click"]}), - ] { - let value = serde_json::json!({ - "url": "http://127.0.0.1:34567/", - "viewports": ["desktop", "mobile"], - "playtestScenario": scenario, - "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") - }); - assert!( - serde_json::from_value::(value).is_err(), - "accepted custom playtest scenario input {scenario}" - ); - } - - for forbidden_field in [ - "selector", - "script", - "playtestUrl", - "headers", - "cookie", - "actions", - ] { - let mut value = serde_json::json!({ - "url": "http://127.0.0.1:34567/", - "viewports": ["desktop", "mobile"], - "playtestScenario": "generic-v1", - "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") - }); - value[forbidden_field] = serde_json::json!("custom"); - assert!( - serde_json::from_value::(value).is_err(), - "accepted forbidden field {forbidden_field}" - ); - } - } - - #[test] - fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { - let generic = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::GenericV1); - let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); - - assert_eq!( - generic, - "dd700c57b0adb3148aecfe2ed839c2c3fa0df89be89b785bf016f4484ad3be46" - ); - assert_eq!( - lane, - "6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a" - ); - assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit())); - assert!(lane.bytes().all(|byte| byte.is_ascii_hexdigit())); - assert_eq!(generic.len(), 64); - assert_eq!(lane.len(), 64); - assert_ne!(generic, lane); - } - - #[test] - fn playable_state_accepts_all_fixed_phases_and_u64_boundaries() { - for phase in ["ready", "playing", "won", "lost"] { - let state = parse_playable_web_game_state( - &serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": u64::MAX, - "phase": phase, - "level": 0 - }) - .to_string(), - BrowserPlaytestScenario::GenericV1, - ) - .expect("parse valid generic state"); - assert_eq!(state.sequence, u64::MAX); - assert_eq!(state.level, 0); - } - - for invalid in [ - r#"{"schemaVersion":"playable-web-game-state.v0","sequence":0,"phase":"ready","level":0}"#, - r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"paused","level":0}"#, - r#"{"schemaVersion":"playable-web-game-state.v1","sequence":-1,"phase":"ready","level":0}"#, - r#"{"schemaVersion":"playable-web-game-state.v1","sequence":1.5,"phase":"ready","level":0}"#, - r#"{"schemaVersion":"playable-web-game-state.v1","sequence":18446744073709551616,"phase":"ready","level":0}"#, - r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":-1}"#, - r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1.5}"#, - r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":18446744073709551616}"#, - ] { - assert!( - parse_playable_web_game_state(invalid, BrowserPlaytestScenario::GenericV1).is_err(), - "accepted invalid state {invalid}" - ); - } - } - - #[test] - fn lane_defense_state_requires_bounded_selection_defenders_and_enemy_metrics() { - let valid = serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 7, - "phase": "playing", - "level": 2, - "selectedDefenderId": null, - "defenders": [], - "enemies": [{ - "id": "enemy-1", - "lane": 0, - "position": -1.25, - "health": 5.5, - "maxHealth": 10 - }] - }); - let state = parse_playable_web_game_state( - &valid.to_string(), - BrowserPlaytestScenario::LaneDefenseV1, - ) - .expect("parse valid lane-defense state"); - assert_eq!(state.defender_count, Some(0)); - assert_eq!(state.enemies.as_ref().map(Vec::len), Some(1)); - - for invalid in [ - serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 0, "phase": "ready", "level": 0, - "defenders": [], "enemies": [] - }), - serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 0, "phase": "ready", "level": 0, - "selectedDefenderId": null, "defenders": {}, "enemies": [] - }), - serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 0, "phase": "playing", "level": 0, - "selectedDefenderId": "defender-1", "defenders": [], - "enemies": [{"id": "enemy-1", "lane": -1, "position": 0, "health": 1, "maxHealth": 1}] - }), - serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 0, "phase": "playing", "level": 0, - "selectedDefenderId": "defender-1", "defenders": [], - "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": -1, "maxHealth": 1}] - }), - serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 0, "phase": "playing", "level": 0, - "selectedDefenderId": "defender-1", "defenders": [], - "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 2, "maxHealth": 1}] - }), - serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 0, "phase": "playing", "level": 0, - "selectedDefenderId": "defender-1", "defenders": [], - "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 0, "maxHealth": 0}] - }), - serde_json::json!({ - "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, - "sequence": 0, "phase": "playing", "level": 0, - "selectedDefenderId": "defender-1", "defenders": [], - "enemies": [{"id": "enemy-1", "lane": "top", "health": 1, "maxHealth": 1}] - }), - ] { - assert!( - parse_playable_web_game_state( - &invalid.to_string(), - BrowserPlaytestScenario::LaneDefenseV1, - ) - .is_err(), - "accepted invalid lane-defense state {invalid}" - ); - } - } - - #[test] - fn lane_enemy_disappearance_counts_as_health_reaching_zero() { - let previous = lane_state( - 10, - BrowserPlaytestPhase::Playing, - vec![PlayableEnemyState { - id: "enemy-1".to_string(), - position: 80.0, - health: 4.0, - }], - ); - let current = lane_state(11, BrowserPlaytestPhase::Won, Vec::new()); - - assert_eq!(lane_enemy_state_changes(&previous, ¤t), (false, true)); - } - - #[test] - fn lane_battle_progress_requires_monotonic_sequence_and_real_changes() { - let initial = lane_state( - 20, - BrowserPlaytestPhase::Playing, - vec![PlayableEnemyState { - id: "enemy-1".to_string(), - position: 100.0, - health: 10.0, - }], - ); - let moved = lane_state( - 21, - BrowserPlaytestPhase::Playing, - vec![PlayableEnemyState { - id: "enemy-1".to_string(), - position: 60.0, - health: 10.0, - }], - ); - let won = lane_state(22, BrowserPlaytestPhase::Won, Vec::new()); - let mut progress = LaneBattleProgress::new(initial.clone()); - progress.observe(&moved); - progress.observe(&won); - - assert!(progress.completed(&won)); - assert!(progress.sequence_advanced); - assert!(progress.sequence_monotonic); - assert!(progress.enemy_position_changed); - assert!(progress.enemy_health_decreased); - - let regressed = lane_state(19, BrowserPlaytestPhase::Won, Vec::new()); - let mut regressed_progress = LaneBattleProgress::new(initial); - regressed_progress.observe(&moved); - regressed_progress.observe(®ressed); - assert!(!regressed_progress.completed(®ressed)); - assert!(!regressed_progress.sequence_monotonic); - } - - #[test] - fn playtest_assertion_summary_requires_every_assertion_and_no_diagnostics() { - let mut assertions = BrowserPlaytestScenario::GenericV1 - .assertion_names() - .iter() - .map(|name| BrowserPlaytestAssertion { - name: (*name).to_string(), - passed: true, - }) - .collect::>(); - assert!(browser_playtest_assertions_passed(&assertions, &[])); - - assertions[0].passed = false; - assert!(!browser_playtest_assertions_passed(&assertions, &[])); - assertions[0].passed = true; - assert!(!browser_playtest_assertions_passed( - &assertions, - &["固定诊断".to_string()] - )); - assert!(!browser_playtest_assertions_passed(&[], &[])); - } - - #[test] - fn classifies_only_multiple_meaningful_canvas_pixel_states_as_non_empty() { - assert_eq!(classify_canvas_pixel_probe(0, 0, 0), None); - assert_eq!(classify_canvas_pixel_probe(4_096, 0, 1), Some(false)); - assert_eq!(classify_canvas_pixel_probe(4_096, 4_096, 1), Some(false)); - assert_eq!(classify_canvas_pixel_probe(4_096, 2_048, 2), Some(true)); - assert_eq!(classify_canvas_pixel_probe(4_096, 4_096, 2), Some(true)); - assert_eq!(classify_canvas_pixel_probe(4, 5, 2), Some(false)); - assert_eq!(classify_canvas_pixel_probe(4, 4, 5), Some(false)); - } - - #[test] - fn requires_a_visible_canvas_with_meaningful_pixel_states() { - let transparent = canvas_snapshot(4_096.0, 4_096, 0, 1).into_evidence(); - let solid = canvas_snapshot(4_096.0, 4_096, 4_096, 1).into_evidence(); - let drawn = canvas_snapshot(4_096.0, 4_096, 4_096, 2).into_evidence(); - let hidden_drawn = canvas_snapshot(0.0, 4_096, 4_096, 2).into_evidence(); - - assert_eq!(transparent.non_empty, Some(false)); - assert_eq!(solid.non_empty, Some(false)); - assert_eq!(drawn.non_empty, Some(true)); - assert_eq!(canvas_validation_diagnostic(&[]), Some("未发现可见 canvas")); - assert_eq!( - canvas_validation_diagnostic(&[hidden_drawn]), - Some("未发现可见 canvas") - ); - assert_eq!( - canvas_validation_diagnostic(&[transparent.clone()]), - Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") - ); - assert_eq!( - canvas_validation_diagnostic(&[solid.clone()]), - Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") - ); - assert_eq!( - canvas_validation_diagnostic(&[transparent, solid]), - Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") - ); - assert_eq!(canvas_validation_diagnostic(&[drawn]), None); - } - - #[test] - fn canvas_probe_serialization_keeps_the_v1_output_structure() { - let value = serde_json::to_value(canvas_snapshot(4_096.0, 4_096, 4_096, 2).into_evidence()) - .expect("serialize canvas evidence"); - let object = value.as_object().expect("canvas evidence object"); - - assert_eq!(object.len(), 9); - for field in [ - "width", - "height", - "cssWidth", - "cssHeight", - "visibleArea", - "sampleCount", - "nonEmptyPixelCount", - "nonEmpty", - "probeError", - ] { - assert!(object.contains_key(field), "missing output field {field}"); - } - assert!(!object.contains_key("distinctPixelStateCount")); - } - - #[test] - fn result_serializes_with_camel_case_evidence_paths() { - let result = BrowserValidationResult { - schema_version: RESULT_SCHEMA_VERSION.to_string(), - url: "http://127.0.0.1:34567/".to_string(), - browser: BrowserIdentity { - kind: DiscoveredBrowserKind::Chrome, - product: "Chrome/1".to_string(), - protocol_version: "1.3".to_string(), - }, - passed: true, - viewport_results: Vec::new(), - playtest: None, - diagnostics: Vec::new(), - evidence: BrowserValidationEvidencePaths { - root: PathBuf::from("/tmp/evidence"), - report_path: PathBuf::from("/tmp/evidence/validation.json"), - }, - completed_at_unix_ms: 1, - }; - let value = serde_json::to_value(&result).expect("serialize result"); - assert_eq!(value["schemaVersion"], RESULT_SCHEMA_VERSION); - assert_eq!(value["completedAtUnixMs"], 1); - assert_eq!( - value["evidence"]["reportPath"], - "/tmp/evidence/validation.json" - ); - assert!(value.get("playtest").is_none()); - assert_eq!( - serde_json::from_value::(value) - .expect("deserialize static result") - .playtest, - None - ); - } - - #[test] - fn persisted_report_uses_only_relative_evidence_paths() { - let evidence_root = PathBuf::from("/tmp/browser-evidence"); - let result = BrowserValidationResult { - schema_version: RESULT_SCHEMA_VERSION.to_string(), - url: "http://127.0.0.1:34567/".to_string(), - browser: BrowserIdentity { - kind: DiscoveredBrowserKind::Chrome, - product: "Chrome/1".to_string(), - protocol_version: "1.3".to_string(), - }, - passed: true, - viewport_results: vec![BrowserViewportValidationResult { - viewport: BrowserValidationViewport::Desktop, - width: 1440, - height: 900, - final_url: "http://127.0.0.1:34567/".to_string(), - title: "fixture".to_string(), - ready_state: "complete".to_string(), - visible_text_summary: "fixture".to_string(), - visible_text_character_count: 7, - dom_character_count: 7, - 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: evidence_root.join("desktop.png"), - passed: true, - diagnostics: Vec::new(), - }], - playtest: None, - diagnostics: Vec::new(), - evidence: BrowserValidationEvidencePaths { - root: evidence_root.clone(), - report_path: evidence_root.join("validation.json"), - }, - completed_at_unix_ms: 1, - }; - - let persisted = - browser_validation_result_for_report(&result).expect("build relative browser report"); - assert_eq!(persisted.evidence.root, PathBuf::from(".")); - assert_eq!( - persisted.evidence.report_path, - PathBuf::from("validation.json") - ); - assert_eq!( - persisted.viewport_results[0].screenshot_path, - PathBuf::from("desktop.png") - ); - assert_eq!(result.evidence.root, evidence_root); - assert!(result.viewport_results[0].screenshot_path.is_absolute()); - } - - #[test] - fn browser_discovery_does_not_trust_a_path_candidate() { - let _guard = PATH_TEST_LOCK.lock().expect("path test lock"); - let directory = tempfile::tempdir().expect("fake browser directory"); - let executable_name = if cfg!(target_os = "windows") { - "chrome.exe" - } else { - "google-chrome" - }; - let fake_browser = directory.path().join(executable_name); - fs::write(&fake_browser, b"not a trusted browser").expect("fake browser"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - let mut permissions = fs::metadata(&fake_browser) - .expect("fake browser metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&fake_browser, permissions).expect("fake browser permissions"); - } - let fake_browser = fake_browser.canonicalize().expect("canonical fake browser"); - let original_path = env::var_os("PATH"); - env::set_var("PATH", directory.path()); - let discovered = discover_chrome_or_edge().ok(); - if let Some(original_path) = original_path { - env::set_var("PATH", original_path); - } else { - env::remove_var("PATH"); - } - - assert_ne!( - discovered.map(|browser| browser.executable_path), - Some(fake_browser) - ); - } - - #[test] - fn browser_discovery_only_builds_absolute_system_candidates() { - assert!(system_browser_candidates() - .iter() - .all(|(path, _kind)| path.is_absolute())); - } - - #[cfg(unix)] - #[test] - fn browser_process_temp_dir_keeps_chrome_singleton_socket_path_short() { - use std::os::unix::ffi::OsStrExt; - - let directory = create_browser_process_temp_dir().expect("browser process tempdir"); - let singleton_socket = directory - .path() - .join("com.google.Chrome.XXXXXX/SingletonSocket"); - - assert!(directory.path().starts_with("/tmp")); - assert!(singleton_socket.as_os_str().as_bytes().len() < 108); - } - - #[test] - fn fetch_interception_covers_all_resources_before_the_request_is_sent() { - let params = preview_fetch_enable_params(); - let patterns = params.patterns.expect("fetch interception patterns"); - - assert_eq!(patterns.len(), 1); - assert_eq!(patterns[0].url_pattern.as_deref(), Some("*")); - assert_eq!(patterns[0].resource_type, None); - assert_eq!(patterns[0].request_stage, Some(RequestStage::Request)); - } - - #[test] - fn request_policy_blocks_cross_origin_http_redirects_and_websockets() { - let origin = Url::parse("http://127.0.0.1:34567/").expect("preview origin"); - - assert_eq!( - preview_request_decision( - "http://127.0.0.1:34567/game.js", - &ResourceType::Script, - false, - &origin, - ), - PreviewRequestDecision::Allow - ); - assert_eq!( - preview_request_decision( - "ws://127.0.0.1:34567/socket", - &ResourceType::WebSocket, - false, - &origin, - ), - PreviewRequestDecision::Allow - ); - for url in [ - "ws://127.0.0.1:34568/socket", - "ws://example.com/socket", - "wss://127.0.0.1:34567/socket", - "http://127.0.0.1:34567/not-a-websocket", - ] { - assert_eq!( - preview_request_decision(url, &ResourceType::WebSocket, false, &origin), - PreviewRequestDecision::Block(PreviewRequestBlockReason::WebSocketBeforeHandshake), - "accepted WebSocket request {url}" - ); - } - assert_eq!( - preview_request_decision( - "http://127.0.0.1:34568/private", - &ResourceType::Fetch, - false, - &origin, - ), - PreviewRequestDecision::Block(PreviewRequestBlockReason::CrossOrigin) - ); - assert_eq!( - preview_request_decision( - "https://example.com/redirect-target", - &ResourceType::Document, - true, - &origin, - ), - PreviewRequestDecision::Block(PreviewRequestBlockReason::RedirectTarget) - ); - } - - #[test] - fn proxy_bypass_is_limited_to_the_preview_http_and_websocket_origin() { - let origin = Url::parse("http://127.0.0.1:34567/").expect("preview origin"); - - assert_eq!( - preview_proxy_bypass_list(&origin), - "<-loopback>;http://127.0.0.1:34567;ws://127.0.0.1:34567" - ); - } - - #[tokio::test] - #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] - async fn real_chrome_blocks_http_redirect_and_websocket_before_connection() { - use std::io::{ErrorKind, Read, Write}; - use std::net::TcpListener; - use std::sync::mpsc; - use std::thread; - - discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); - let blocked_listener = - TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind blocked origin"); - let blocked_port = blocked_listener - .local_addr() - .expect("blocked origin address") - .port(); - blocked_listener - .set_nonblocking(true) - .expect("nonblocking blocked origin"); - - let preview_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); - let preview_port = preview_listener - .local_addr() - .expect("preview address") - .port(); - preview_listener - .set_nonblocking(true) - .expect("nonblocking preview"); - let html = format!( - r#"
Network policy probe
"# - ) - .into_bytes(); - let (stop_tx, stop_rx) = mpsc::channel(); - let server = thread::spawn(move || { - while stop_rx.try_recv().is_err() { - match preview_listener.accept() { - Ok((mut stream, _)) => { - let mut request = [0_u8; 2048]; - let count = stream.read(&mut request).unwrap_or_default(); - let request = String::from_utf8_lossy(&request[..count]); - if request.starts_with("GET /redirect ") { - let response = format!( - "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{blocked_port}/redirected\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - ); - let _ = stream.write_all(response.as_bytes()); - } else { - let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - html.len() - ); - let _ = stream.write_all(headers.as_bytes()); - let _ = stream.write_all(&html); - } - } - Err(error) if error.kind() == ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("preview accept failed: {error}"), - } - } - }); - - let evidence = tempfile::tempdir().expect("evidence tempdir"); - let validation = validate_local_preview_in_browser(BrowserValidationInput { - url: format!("http://127.0.0.1:{preview_port}/"), - viewports: REQUIRED_VIEWPORTS.to_vec(), - expected_text: vec!["Network policy probe".to_string()], - settle_ms: 200, - fail_on_console_error: false, - playtest_scenario: None, - evidence_root: evidence.path().join("evidence"), - }) - .await; - let _ = stop_tx.send(()); - server.join().expect("preview server"); - let result = validation.expect("real browser network validation"); - assert_eq!(result.viewport_results.len(), REQUIRED_VIEWPORTS.len()); - assert!(result.viewport_results.iter().all(|viewport| { - !viewport.passed - && viewport - .diagnostics - .iter() - .any(|diagnostic| diagnostic == "未发现可见 canvas") - })); - let failed_requests = &result.viewport_results[0].failed_requests; - - assert!(!result.passed); - assert!( - failed_requests.iter().any(|request| { - request.blocked_by_policy - && request.url == format!("http://127.0.0.1:{blocked_port}/direct") - && request.error_text == PreviewRequestBlockReason::CrossOrigin.message() - }), - "{failed_requests:#?}" - ); - assert!( - failed_requests.iter().any(|request| { - request.blocked_by_policy - && request.url == format!("http://127.0.0.1:{blocked_port}/redirected") - }), - "{failed_requests:#?}" - ); - assert!( - failed_requests.iter().any(|request| { - request.blocked_by_policy - && request.resource_type == "WebSocket" - && request.error_text - == PreviewRequestBlockReason::WebSocketBeforeHandshake.message() - }), - "{failed_requests:#?}" - ); - thread::sleep(Duration::from_millis(100)); - match blocked_listener.accept() { - Err(error) if error.kind() == ErrorKind::WouldBlock => {} - Ok(_) => panic!("blocked origin received a TCP connection"), - Err(error) => panic!("blocked origin accept failed: {error}"), - } - } - - #[tokio::test] - #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] - async fn real_chrome_lane_defense_playtest() { - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::sync::mpsc; - use std::thread; - - discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); - let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); - let port = listener.local_addr().expect("preview address").port(); - listener.set_nonblocking(true).expect("nonblocking preview"); - let html = br#" - -Lane Defense Browser Fixture - -
Lane defense fixture
- -
- - - - - - -
- - - -"#; - let (stop_tx, stop_rx) = mpsc::channel(); - let server = thread::spawn(move || { - while stop_rx.try_recv().is_err() { - match listener.accept() { - Ok((mut stream, _)) => { - let mut request = [0_u8; 2048]; - let _ = stream.read(&mut request); - let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - html.len() - ); - let _ = stream.write_all(headers.as_bytes()); - let _ = stream.write_all(html); - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("preview accept failed: {error}"), - } - } - }); - - let evidence = tempfile::tempdir().expect("evidence tempdir"); - let validation = validate_local_preview_in_browser(BrowserValidationInput { - url: format!("http://127.0.0.1:{port}/"), - viewports: REQUIRED_VIEWPORTS.to_vec(), - expected_text: vec!["Lane defense fixture".to_string()], - settle_ms: 100, - fail_on_console_error: true, - playtest_scenario: Some(BrowserPlaytestScenario::LaneDefenseV1), - evidence_root: evidence.path().join("evidence"), - }) - .await; - let _ = stop_tx.send(()); - server.join().expect("preview server"); - - let result = validation.expect("real lane-defense browser validation"); - assert!(result.passed, "{:#?}", result.diagnostics); - assert!(result.evidence.report_path.is_file()); - assert!(result.viewport_results.iter().all(|viewport| { - viewport.passed - && viewport - .canvases - .iter() - .any(|canvas| canvas.non_empty == Some(true)) - })); - let playtest = result.playtest.expect("lane-defense playtest result"); - assert!(playtest.passed, "{:#?}", playtest.diagnostics); - assert_eq!(playtest.scenario, BrowserPlaytestScenario::LaneDefenseV1); - assert_eq!(playtest.initial_sequence, Some(0)); - assert_eq!(playtest.initial_phase, Some(BrowserPlaytestPhase::Ready)); - assert_eq!(playtest.initial_level, Some(1)); - assert_eq!(playtest.final_sequence, Some(9)); - assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); - assert_eq!(playtest.final_level, Some(2)); - assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); - } - - #[tokio::test] - #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] - async fn real_chrome_validation_smoke() { - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::sync::mpsc; - use std::thread; - - discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); - let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); - let port = listener.local_addr().expect("preview address").port(); - listener.set_nonblocking(true).expect("nonblocking preview"); - let (stop_tx, stop_rx) = mpsc::channel(); - let server = thread::spawn(move || { - let html = br#"Browser Probe
Expected local preview
"#; - while stop_rx.try_recv().is_err() { - match listener.accept() { - Ok((mut stream, _)) => { - let mut request = [0_u8; 2048]; - let _ = stream.read(&mut request); - let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - html.len() - ); - let _ = stream.write_all(headers.as_bytes()); - let _ = stream.write_all(html); - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("preview accept failed: {error}"), - } - } - }); - let evidence = tempfile::tempdir().expect("evidence tempdir"); - let result = validate_local_preview_in_browser(BrowserValidationInput { - url: format!("http://127.0.0.1:{port}/"), - viewports: REQUIRED_VIEWPORTS.to_vec(), - expected_text: vec!["Expected local preview".to_string()], - settle_ms: 100, - fail_on_console_error: true, - playtest_scenario: None, - evidence_root: evidence.path().join("evidence"), - }) - .await - .expect("real browser validation"); - let _ = stop_tx.send(()); - server.join().expect("preview server"); - assert!(result.passed, "{:?}", result.diagnostics); - assert_eq!(result.viewport_results.len(), REQUIRED_VIEWPORTS.len()); - assert!(result.evidence.report_path.is_file()); - for (viewport_result, expected_viewport) in - result.viewport_results.iter().zip(REQUIRED_VIEWPORTS) - { - let (width, height, _) = expected_viewport.dimensions(); - assert_eq!(viewport_result.viewport, expected_viewport); - assert_eq!( - (viewport_result.width, viewport_result.height), - (width, height) - ); - assert!(viewport_result.screenshot_path.is_file()); - assert!(viewport_result.expected_text[0].found); - assert!(viewport_result - .console_warnings - .iter() - .any(|warning| warning.text.contains("probe warning"))); - assert_eq!(viewport_result.canvases.len(), 3); - assert_eq!(viewport_result.canvases[0].non_empty_pixel_count, 0); - assert_eq!(viewport_result.canvases[0].non_empty, Some(false)); - assert_eq!( - viewport_result.canvases[1].non_empty_pixel_count, - viewport_result.canvases[1].sample_count - ); - assert_eq!(viewport_result.canvases[1].non_empty, Some(false)); - assert_eq!(viewport_result.canvases[2].non_empty, Some(true)); - } - } -} +mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs new file mode 100644 index 000000000..c9120792b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs @@ -0,0 +1,733 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use chromiumoxide::cdp::browser_protocol::fetch::{ + ContinueRequestParams, EnableParams as FetchEnableParams, EventRequestPaused, + FailRequestParams, RequestPattern, RequestStage, +}; +use chromiumoxide::cdp::browser_protocol::network::{ + ErrorReason, EventLoadingFailed, EventRequestWillBeSent, EventResponseReceived, + EventWebSocketCreated, EventWebSocketFrameError, EventWebSocketWillSendHandshakeRequest, + ResourceType, +}; +use chromiumoxide::cdp::browser_protocol::page::{ + EventJavascriptDialogOpening, HandleJavaScriptDialogParams, +}; +use chromiumoxide::cdp::js_protocol::runtime::{ + ConsoleApiCalledType, EventConsoleApiCalled, EventExceptionThrown, RemoteObject, +}; +use chromiumoxide::Page; +use futures::StreamExt; +use serde::Deserialize; +use tokio::task::JoinHandle; +use url::Url; + +use super::model::{ + BrowserCanvasProbe, BrowserConsoleMessage, BrowserException, BrowserFailedRequest, + MAX_URL_CHARS, +}; +use super::network_policy::{preview_request_decision, PreviewRequestDecision}; + +const MAX_VISIBLE_TEXT_CHARS: usize = 4_000; +const MAX_EVENT_TEXT_CHARS: usize = 2_000; +const MAX_CAPTURED_EVENTS: usize = 100; +const MAX_TRACKED_REQUESTS: usize = 2_048; + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(super) struct BrowserCanvasSnapshot { + pub(super) width: u32, + pub(super) height: u32, + pub(super) css_width: f64, + pub(super) css_height: f64, + pub(super) visible_area: f64, + pub(super) sample_count: u32, + pub(super) non_empty_pixel_count: u32, + pub(super) distinct_pixel_state_count: u32, + pub(super) probe_error: Option, +} + +impl BrowserCanvasSnapshot { + pub(super) fn into_evidence(self) -> BrowserCanvasProbe { + let non_empty = if self.probe_error.is_some() { + None + } else { + classify_canvas_pixel_probe( + self.sample_count, + self.non_empty_pixel_count, + self.distinct_pixel_state_count, + ) + }; + BrowserCanvasProbe { + width: self.width, + height: self.height, + css_width: self.css_width, + css_height: self.css_height, + visible_area: self.visible_area, + sample_count: self.sample_count, + non_empty_pixel_count: self.non_empty_pixel_count, + non_empty, + probe_error: self.probe_error, + } + } +} + +pub(super) fn classify_canvas_pixel_probe( + sample_count: u32, + non_empty_pixel_count: u32, + distinct_pixel_state_count: u32, +) -> Option { + if sample_count == 0 { + return None; + } + Some( + non_empty_pixel_count > 0 + && non_empty_pixel_count <= sample_count + && distinct_pixel_state_count >= 2 + && distinct_pixel_state_count <= sample_count, + ) +} + +pub(super) fn canvas_validation_diagnostic( + canvases: &[BrowserCanvasProbe], +) -> Option<&'static str> { + let mut visible_canvases = canvases.iter().filter(|canvas| canvas.visible_area > 0.0); + let Some(first_visible) = visible_canvases.next() else { + return Some("未发现可见 canvas"); + }; + if first_visible.non_empty == Some(true) + || visible_canvases.any(|canvas| canvas.non_empty == Some(true)) + { + None + } else { + Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") + } +} + +#[derive(Clone, Debug)] +struct RequestInfo { + url: String, + method: String, + resource_type: String, +} + +#[derive(Default)] +pub(super) struct CaptureState { + requests: HashMap, + pub(super) console_errors: Vec, + pub(super) console_warnings: Vec, + pub(super) exceptions: Vec, + pub(super) failed_requests: Vec, + pub(super) blocked_dialog_count: u32, + pub(super) infrastructure_errors: Vec, +} + +impl CaptureState { + fn push_failed_request(&mut self, request: BrowserFailedRequest) { + if self.failed_requests.len() >= MAX_CAPTURED_EVENTS { + return; + } + if !self.failed_requests.iter().any(|existing| { + existing.url == request.url + && existing.method == request.method + && existing.error_text == request.error_text + && existing.status_code == request.status_code + }) { + self.failed_requests.push(request); + } + } + + fn push_infrastructure_error(&mut self, error: String) { + if self.infrastructure_errors.len() < 8 { + self.infrastructure_errors + .push(truncate_chars(&error, MAX_EVENT_TEXT_CHARS)); + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PageSnapshot { + pub(super) final_url: String, + pub(super) title: String, + pub(super) ready_state: String, + pub(super) visible_text_summary: String, + pub(super) visible_text_character_count: usize, + pub(super) dom_character_count: usize, + pub(super) expected_text_matches: Vec, + pub(super) canvases: Vec, + pub(super) blocked_popup_count: u32, + pub(super) blocked_download_count: u32, + pub(super) blocked_permission_count: u32, + pub(super) blocked_service_worker_count: u32, +} + +pub(super) struct CaptureTasks { + state: Arc>, + handles: Vec>, +} + +impl CaptureTasks { + pub(super) async fn stop(self) -> Result { + for handle in &self.handles { + handle.abort(); + } + for handle in self.handles { + let _ = handle.await; + } + Arc::try_unwrap(self.state) + .map_err(|_| "browser capture state is still in use".to_string())? + .into_inner() + .map_err(|_| "browser capture state lock is poisoned".to_string()) + } +} +pub(super) fn preview_fetch_enable_params() -> FetchEnableParams { + FetchEnableParams::builder() + .pattern( + RequestPattern::builder() + .url_pattern("*") + .request_stage(RequestStage::Request) + .build(), + ) + .build() +} + +pub(super) async fn start_capture_tasks( + page: &Page, + preview_url: &Url, +) -> Result { + let mut paused = page + .event_listener::() + .await + .map_err(|error| format!("监听请求拦截失败:{error}"))?; + page.execute(preview_fetch_enable_params()) + .await + .map_err(|error| format!("启用请求阶段安全拦截失败:{error}"))?; + let mut request_events = page + .event_listener::() + .await + .map_err(|error| format!("监听网络请求失败:{error}"))?; + let mut loading_failed = page + .event_listener::() + .await + .map_err(|error| format!("监听失败请求失败:{error}"))?; + let mut responses = page + .event_listener::() + .await + .map_err(|error| format!("监听 HTTP 响应失败:{error}"))?; + let mut websockets = page + .event_listener::() + .await + .map_err(|error| format!("监听 WebSocket 失败:{error}"))?; + let mut websocket_handshakes = page + .event_listener::() + .await + .map_err(|error| format!("监听 WebSocket 握手失败:{error}"))?; + let mut websocket_errors = page + .event_listener::() + .await + .map_err(|error| format!("监听 WebSocket 错误失败:{error}"))?; + let mut console = page + .event_listener::() + .await + .map_err(|error| format!("监听 console 失败:{error}"))?; + let mut exceptions = page + .event_listener::() + .await + .map_err(|error| format!("监听异常失败:{error}"))?; + let mut dialogs = page + .event_listener::() + .await + .map_err(|error| format!("监听弹窗失败:{error}"))?; + + let state = Arc::new(Mutex::new(CaptureState::default())); + let mut handles = Vec::new(); + + let task_page = page.clone(); + let task_state = state.clone(); + let origin = preview_url.clone(); + handles.push(tokio::spawn(async move { + while let Some(event) = paused.next().await { + let decision = preview_request_decision( + &event.request.url, + &event.resource_type, + event.redirected_request_id.is_some(), + &origin, + ); + if let PreviewRequestDecision::Block(reason) = decision { + if let Ok(mut capture) = task_state.lock() { + capture.push_failed_request(BrowserFailedRequest { + url: sanitize_url(&event.request.url), + method: event.request.method.clone(), + resource_type: event.resource_type.as_ref().to_string(), + error_text: reason.message().to_string(), + status_code: None, + canceled: true, + blocked_by_policy: true, + fatal: true, + }); + } + if let Err(error) = task_page + .execute(FailRequestParams::new( + event.request_id.clone(), + ErrorReason::BlockedByClient, + )) + .await + { + record_capture_error(&task_state, format!("阻止跨 origin 请求失败:{error}")); + break; + } + } else if let Err(error) = task_page + .execute(ContinueRequestParams::new(event.request_id.clone())) + .await + { + record_capture_error(&task_state, format!("放行同 origin 请求失败:{error}")); + break; + } + } + })); + + let task_state = state.clone(); + handles.push(tokio::spawn(async move { + while let Some(event) = request_events.next().await { + if let Ok(mut capture) = task_state.lock() { + if capture.requests.len() < MAX_TRACKED_REQUESTS { + capture.requests.insert( + event.request_id.inner().clone(), + RequestInfo { + url: event.request.url.clone(), + method: event.request.method.clone(), + resource_type: event + .r#type + .as_ref() + .map(|value| value.as_ref().to_string()) + .unwrap_or_else(|| "Other".to_string()), + }, + ); + } + } + } + })); + + let task_state = state.clone(); + handles.push(tokio::spawn(async move { + while let Some(event) = loading_failed.next().await { + if let Ok(mut capture) = task_state.lock() { + let info = capture.requests.get(event.request_id.inner()).cloned(); + let canceled = event.canceled.unwrap_or(false); + let error_text = truncate_chars(&event.error_text, MAX_EVENT_TEXT_CHARS); + let fatal = !(canceled && error_text.contains("ERR_ABORTED")); + capture.push_failed_request(BrowserFailedRequest { + url: sanitize_url(info.as_ref().map(|value| value.url.as_str()).unwrap_or("")), + method: info + .as_ref() + .map(|value| value.method.clone()) + .unwrap_or_else(|| "GET".to_string()), + resource_type: info + .as_ref() + .map(|value| value.resource_type.clone()) + .unwrap_or_else(|| event.r#type.as_ref().to_string()), + error_text, + status_code: None, + canceled, + blocked_by_policy: false, + fatal, + }); + } + } + })); + + let task_state = state.clone(); + handles.push(tokio::spawn(async move { + while let Some(event) = responses.next().await { + if event.response.status < 400 { + continue; + } + if let Ok(mut capture) = task_state.lock() { + let info = capture.requests.get(event.request_id.inner()).cloned(); + let status = u16::try_from(event.response.status).unwrap_or(u16::MAX); + let url = event.response.url.clone(); + let favicon_404 = status == 404 + && Url::parse(&url) + .ok() + .map(|value| value.path() == "/favicon.ico") + .unwrap_or(false); + capture.push_failed_request(BrowserFailedRequest { + url: sanitize_url(&url), + method: info + .as_ref() + .map(|value| value.method.clone()) + .unwrap_or_else(|| "GET".to_string()), + resource_type: event.r#type.as_ref().to_string(), + error_text: format!("HTTP {status}"), + status_code: Some(status), + canceled: false, + blocked_by_policy: false, + fatal: !favicon_404, + }); + } + } + })); + + let task_state = state.clone(); + let origin = preview_url.clone(); + handles.push(tokio::spawn(async move { + loop { + tokio::select! { + biased; + event = websockets.next() => { + let Some(event) = event else { + break; + }; + if let Ok(mut capture) = task_state.lock() { + capture.requests.insert( + event.request_id.inner().clone(), + RequestInfo { + url: event.url.clone(), + method: "GET".to_string(), + resource_type: "WebSocket".to_string(), + }, + ); + if let PreviewRequestDecision::Block(reason) = preview_request_decision( + &event.url, + &ResourceType::WebSocket, + false, + &origin, + ) { + capture.push_failed_request(BrowserFailedRequest { + url: sanitize_url(&event.url), + method: "GET".to_string(), + resource_type: "WebSocket".to_string(), + error_text: reason.message().to_string(), + status_code: None, + canceled: true, + blocked_by_policy: true, + fatal: true, + }); + } + } + } + event = websocket_handshakes.next() => { + let Some(event) = event else { + break; + }; + if let Ok(mut capture) = task_state.lock() { + match capture.requests.get(event.request_id.inner()).cloned() { + Some(info) + if matches!( + preview_request_decision( + &info.url, + &ResourceType::WebSocket, + false, + &origin, + ), + PreviewRequestDecision::Block(_) + ) => + { + capture.push_infrastructure_error( + "跨 origin WebSocket 已进入握手阶段".to_string(), + ); + } + None => capture.push_infrastructure_error( + "无法核对 WebSocket 握手 origin".to_string(), + ), + _ => {} + } + } + } + } + } + })); + + let task_state = state.clone(); + handles.push(tokio::spawn(async move { + while let Some(event) = websocket_errors.next().await { + if let Ok(mut capture) = task_state.lock() { + let info = capture.requests.get(event.request_id.inner()).cloned(); + capture.push_failed_request(BrowserFailedRequest { + url: sanitize_url(info.as_ref().map(|value| value.url.as_str()).unwrap_or("")), + method: "GET".to_string(), + resource_type: "WebSocket".to_string(), + error_text: truncate_chars(&event.error_message, MAX_EVENT_TEXT_CHARS), + status_code: None, + canceled: false, + blocked_by_policy: false, + fatal: true, + }); + } + } + })); + + let task_state = state.clone(); + handles.push(tokio::spawn(async move { + while let Some(event) = console.next().await { + let level = match event.r#type { + ConsoleApiCalledType::Error | ConsoleApiCalledType::Assert => "error", + ConsoleApiCalledType::Warning => "warning", + _ => continue, + }; + let location = event + .stack_trace + .as_ref() + .and_then(|trace| trace.call_frames.first()); + let message = BrowserConsoleMessage { + level: level.to_string(), + text: truncate_chars( + &event + .args + .iter() + .map(remote_object_text) + .collect::>() + .join(" "), + MAX_EVENT_TEXT_CHARS, + ), + source_url: location.map(|frame| sanitize_url(&frame.url)), + line_number: location.map(|frame| nonnegative_u32(frame.line_number)), + column_number: location.map(|frame| nonnegative_u32(frame.column_number)), + }; + if let Ok(mut capture) = task_state.lock() { + let target = if level == "error" { + &mut capture.console_errors + } else { + &mut capture.console_warnings + }; + if target.len() < MAX_CAPTURED_EVENTS { + target.push(message); + } + } + } + })); + + let task_state = state.clone(); + handles.push(tokio::spawn(async move { + while let Some(event) = exceptions.next().await { + let details = &event.exception_details; + let text = details + .exception + .as_ref() + .and_then(|value| value.description.as_deref()) + .unwrap_or(&details.text); + if let Ok(mut capture) = task_state.lock() { + if capture.exceptions.len() < MAX_CAPTURED_EVENTS { + capture.exceptions.push(BrowserException { + text: truncate_chars(text, MAX_EVENT_TEXT_CHARS), + source_url: details.url.as_deref().map(sanitize_url), + line_number: nonnegative_u32(details.line_number), + column_number: nonnegative_u32(details.column_number), + }); + } + } + } + })); + + let task_page = page.clone(); + let task_state = state.clone(); + handles.push(tokio::spawn(async move { + while dialogs.next().await.is_some() { + if let Ok(mut capture) = task_state.lock() { + capture.blocked_dialog_count = capture.blocked_dialog_count.saturating_add(1); + } + if let Err(error) = task_page + .execute(HandleJavaScriptDialogParams::new(false)) + .await + { + record_capture_error(&task_state, format!("关闭 JavaScript 弹窗失败:{error}")); + break; + } + } + })); + + Ok(CaptureTasks { state, handles }) +} + +fn record_capture_error(state: &Arc>, error: String) { + if let Ok(mut capture) = state.lock() { + capture.push_infrastructure_error(error); + } +} +pub(super) fn build_snapshot_script(expected_text: &[String]) -> Result { + let expected = serde_json::to_string(expected_text) + .map_err(|error| format!("序列化 expectedText 失败:{error}"))?; + Ok(format!( + r#"(() => {{ + const expected = {expected}; + const text = String(document.body?.innerText || '').replace(/\s+/g, ' ').trim(); + const security = window.__GENARRATIVE_PREVIEW_VALIDATION__ || {{}}; + const canvases = Array.from(document.querySelectorAll('canvas')).slice(0, 32).map((canvas) => {{ + const rect = canvas.getBoundingClientRect(); + const style = getComputedStyle(canvas); + const visibleWidth = Math.max(0, Math.min(rect.right, innerWidth) - Math.max(rect.left, 0)); + const visibleHeight = Math.max(0, Math.min(rect.bottom, innerHeight) - Math.max(rect.top, 0)); + const visibleArea = style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0 + ? 0 : Math.round(visibleWidth * visibleHeight); + let sampleCount = 0; + let nonEmptyPixelCount = 0; + let distinctPixelStateCount = 0; + let probeError = null; + if (canvas.width > 0 && canvas.height > 0 && visibleArea > 0) {{ + try {{ + const probe = document.createElement('canvas'); + probe.width = Math.min(64, canvas.width); + probe.height = Math.min(64, canvas.height); + const context = probe.getContext('2d', {{ willReadFrequently: true }}); + context.drawImage(canvas, 0, 0, probe.width, probe.height); + const pixels = context.getImageData(0, 0, probe.width, probe.height).data; + sampleCount = pixels.length / 4; + let firstPixelState = null; + for (let index = 0; index < pixels.length; index += 4) {{ + if (pixels[index + 3] !== 0) nonEmptyPixelCount += 1; + const pixelState = pixels[index] * 0x1000000 + + pixels[index + 1] * 0x10000 + + pixels[index + 2] * 0x100 + + pixels[index + 3]; + if (firstPixelState === null) {{ + firstPixelState = pixelState; + distinctPixelStateCount = 1; + }} else if (distinctPixelStateCount === 1 && pixelState !== firstPixelState) {{ + distinctPixelStateCount = 2; + }} + }} + }} catch (error) {{ + probeError = String(error).slice(0, 512); + }} + }} + return {{ + width: canvas.width, + height: canvas.height, + cssWidth: rect.width, + cssHeight: rect.height, + visibleArea, + sampleCount, + nonEmptyPixelCount, + distinctPixelStateCount, + probeError + }}; + }}); + return {{ + finalUrl: location.href, + title: document.title, + readyState: document.readyState, + visibleTextSummary: text.slice(0, {MAX_VISIBLE_TEXT_CHARS}), + visibleTextCharacterCount: text.length, + domCharacterCount: document.documentElement?.outerHTML?.length || 0, + expectedTextMatches: expected.map((value) => text.includes(value)), + canvases, + blockedPopupCount: security.blockedPopupCount || 0, + blockedDownloadCount: security.blockedDownloadCount || 0, + blockedPermissionCount: security.blockedPermissionCount || 0, + blockedServiceWorkerCount: security.blockedServiceWorkerCount || 0 + }}; +}})()"# + )) +} + +pub(super) const RESTRICTION_SCRIPT: &str = r#" +(() => { + const state = { + blockedPopupCount: 0, + blockedDownloadCount: 0, + blockedPermissionCount: 0, + blockedServiceWorkerCount: 0 + }; + Object.defineProperty(window, '__GENARRATIVE_PREVIEW_VALIDATION__', { + value: state, + configurable: false, + enumerable: false, + writable: false + }); + const blockPopup = () => { state.blockedPopupCount += 1; return null; }; + try { Object.defineProperty(window, 'open', { value: blockPopup, configurable: false }); } + catch (_) { window.open = blockPopup; } + document.addEventListener('click', (event) => { + const anchor = event.target?.closest?.('a'); + if (!anchor) return; + if (anchor.hasAttribute('download')) { + state.blockedDownloadCount += 1; + event.preventDefault(); + } + if (anchor.target && anchor.target.toLowerCase() !== '_self') { + state.blockedPopupCount += 1; + event.preventDefault(); + } + }, true); + document.addEventListener('submit', (event) => { + const target = event.target?.target; + if (target && target.toLowerCase() !== '_self') { + state.blockedPopupCount += 1; + event.preventDefault(); + } + }, true); + const denied = () => { + state.blockedPermissionCount += 1; + return Promise.reject(new DOMException('Permission denied during preview validation', 'NotAllowedError')); + }; + if (navigator.mediaDevices) { + try { navigator.mediaDevices.getUserMedia = denied; } catch (_) {} + try { navigator.mediaDevices.getDisplayMedia = denied; } catch (_) {} + } + if (navigator.clipboard) { + try { navigator.clipboard.read = denied; } catch (_) {} + try { navigator.clipboard.readText = denied; } catch (_) {} + try { navigator.clipboard.write = denied; } catch (_) {} + try { navigator.clipboard.writeText = denied; } catch (_) {} + } + if (navigator.geolocation) { + const geolocationDenied = (_success, failure) => { + state.blockedPermissionCount += 1; + if (failure) failure({ code: 1, message: 'Permission denied during preview validation' }); + }; + try { navigator.geolocation.getCurrentPosition = geolocationDenied; } catch (_) {} + try { navigator.geolocation.watchPosition = geolocationDenied; } catch (_) {} + } + if (window.Notification?.requestPermission) { + try { + Notification.requestPermission = () => { + state.blockedPermissionCount += 1; + return Promise.resolve('denied'); + }; + } catch (_) {} + } + if (navigator.serviceWorker) { + try { + const prototype = Object.getPrototypeOf(navigator.serviceWorker); + Object.defineProperty(prototype, 'register', { + value: () => { + state.blockedServiceWorkerCount += 1; + return Promise.reject(new DOMException('Service Worker disabled during preview validation', 'SecurityError')); + }, + configurable: false + }); + } catch (_) {} + } +})(); +"#; + +fn remote_object_text(object: &RemoteObject) -> String { + if let Some(value) = &object.value { + if let Some(value) = value.as_str() { + return value.to_string(); + } + return value.to_string(); + } + object + .description + .clone() + .unwrap_or_else(|| object.r#type.as_ref().to_string()) +} + +pub(super) fn sanitize_url(raw: &str) -> String { + let Ok(mut url) = Url::parse(raw) else { + return truncate_chars(raw, MAX_URL_CHARS); + }; + url.set_query(None); + url.set_fragment(None); + truncate_chars(url.as_str(), MAX_URL_CHARS) +} + +pub(super) fn truncate_chars(value: &str, limit: usize) -> String { + value.chars().take(limit).collect() +} + +fn nonnegative_u32(value: i64) -> u32 { + u32::try_from(value).unwrap_or_default() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs new file mode 100644 index 000000000..e56a8a0ad --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs @@ -0,0 +1,277 @@ +use std::time::Duration; + +use chromiumoxide::browser::Browser; +use chromiumoxide::cdp::browser_protocol::browser::{ + SetDownloadBehaviorBehavior, SetDownloadBehaviorParams, +}; +use chromiumoxide::cdp::browser_protocol::emulation::{ + SetDeviceMetricsOverrideParams, SetTouchEmulationEnabledParams, +}; +use chromiumoxide::cdp::browser_protocol::network::SetBypassServiceWorkerParams; +use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat; +use chromiumoxide::page::ScreenshotParams; +use url::Url; + +use super::capture::{ + build_snapshot_script, canvas_validation_diagnostic, sanitize_url, start_capture_tasks, + truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RESTRICTION_SCRIPT, +}; +use super::evidence::write_atomic; +use super::model::{ + BrowserExpectedTextMatch, BrowserIdentity, BrowserPlaytestResult, + BrowserValidationEvidencePaths, BrowserValidationInput, BrowserValidationResult, + BrowserValidationViewport, BrowserViewportValidationResult, DiscoveredBrowser, BROWSER_TIMEOUT, + REQUIRED_VIEWPORTS, RESULT_SCHEMA_VERSION, +}; +use super::network_policy::same_preview_origin; +use super::playtest::run_desktop_playtest; + +struct BrowserViewportValidationOutcome { + result: BrowserViewportValidationResult, + playtest: Option, +} + +pub(super) async fn run_browser_validation( + browser: &Browser, + discovered: &DiscoveredBrowser, + preview_url: &Url, + input: &BrowserValidationInput, +) -> Result { + browser + .execute(SetDownloadBehaviorParams::new( + SetDownloadBehaviorBehavior::Deny, + )) + .await + .map_err(|error| format!("禁用浏览器下载失败:{error}"))?; + let version = browser + .version() + .await + .map_err(|error| format!("读取浏览器版本失败:{error}"))?; + + let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len()); + let mut playtest = None; + for viewport in REQUIRED_VIEWPORTS { + let outcome = validate_viewport(browser, preview_url, input, viewport).await?; + if outcome.playtest.is_some() { + playtest = outcome.playtest; + } + viewport_results.push(outcome.result); + } + let mut diagnostics = viewport_results + .iter() + .flat_map(|result| { + result + .diagnostics + .iter() + .map(move |message| format!("{}: {message}", result.viewport.file_stem())) + }) + .collect::>(); + let playtest_passed = match (&input.playtest_scenario, &playtest) { + (None, None) => true, + (Some(_), Some(result)) => { + if !result.passed { + diagnostics.extend( + result + .diagnostics + .iter() + .map(|message| format!("playtest: {message}")), + ); + } + result.passed + } + (Some(_), None) => { + diagnostics.push("playtest: desktop 试玩结果缺失".to_string()); + false + } + (None, Some(_)) => { + diagnostics.push("playtest: 未请求试玩却产生了试玩结果".to_string()); + false + } + }; + let passed = viewport_results.iter().all(|result| result.passed) && playtest_passed; + let report_path = input.evidence_root.join("validation.json"); + + Ok(BrowserValidationResult { + schema_version: RESULT_SCHEMA_VERSION.to_string(), + url: preview_url.as_str().to_string(), + browser: BrowserIdentity { + kind: discovered.kind, + product: version.product, + protocol_version: version.protocol_version, + }, + passed, + viewport_results, + playtest, + diagnostics, + evidence: BrowserValidationEvidencePaths { + root: input.evidence_root.clone(), + report_path, + }, + completed_at_unix_ms: 0, + }) +} + +async fn validate_viewport( + browser: &Browser, + preview_url: &Url, + input: &BrowserValidationInput, + viewport: BrowserValidationViewport, +) -> Result { + let (width, height, mobile) = viewport.dimensions(); + let page = browser + .new_page("about:blank") + .await + .map_err(|error| format!("创建 {} 页面失败:{error}", viewport.file_stem()))?; + page.execute(SetDeviceMetricsOverrideParams::new( + i64::from(width), + i64::from(height), + 1.0, + mobile, + )) + .await + .map_err(|error| format!("设置 {} 视口失败:{error}", viewport.file_stem()))?; + page.execute(SetTouchEmulationEnabledParams::new(mobile)) + .await + .map_err(|error| format!("设置触摸模拟失败:{error}"))?; + page.execute(SetBypassServiceWorkerParams::new(true)) + .await + .map_err(|error| format!("绕过 Service Worker 失败:{error}"))?; + page.evaluate_on_new_document(RESTRICTION_SCRIPT) + .await + .map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?; + + let tasks = start_capture_tasks(&page, preview_url).await?; + let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str())) + .await + .map_err(|_| format!("{} 页面导航超时", viewport.file_stem()))?; + if let Err(error) = navigation { + let _ = tasks.stop().await; + let _ = page.close().await; + return Err(format!("{} 页面导航失败:{error}", viewport.file_stem())); + } + tokio::time::sleep(Duration::from_millis(input.settle_ms)).await; + + let playtest = if viewport == BrowserValidationViewport::Desktop { + match input.playtest_scenario { + Some(scenario) => Some(run_desktop_playtest(&page, scenario).await), + None => None, + } + } else { + None + }; + + let snapshot_script = build_snapshot_script(&input.expected_text)?; + let snapshot: PageSnapshot = page + .evaluate(snapshot_script) + .await + .map_err(|error| format!("采集 {} 页面状态失败:{error}", viewport.file_stem()))? + .into_value() + .map_err(|error| format!("解析 {} 页面状态失败:{error}", viewport.file_stem()))?; + let screenshot = page + .screenshot( + ScreenshotParams::builder() + .format(CaptureScreenshotFormat::Png) + .full_page(false) + .capture_beyond_viewport(false) + .build(), + ) + .await + .map_err(|error| format!("采集 {} PNG 失败:{error}", viewport.file_stem()))?; + if !screenshot.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(format!("{} 截图不是有效 PNG", viewport.file_stem())); + } + let screenshot_path = input + .evidence_root + .join(format!("{}.png", viewport.file_stem())); + write_atomic(&screenshot_path, &screenshot)?; + tokio::task::yield_now().await; + + let capture = tasks.stop().await?; + let _ = page.close().await; + if !capture.infrastructure_errors.is_empty() { + return Err(format!( + "浏览器安全拦截失败:{}", + capture.infrastructure_errors.join(";") + )); + } + + let expected_text = input + .expected_text + .iter() + .enumerate() + .map(|(index, text)| BrowserExpectedTextMatch { + text: text.clone(), + found: snapshot + .expected_text_matches + .get(index) + .copied() + .unwrap_or(false), + }) + .collect::>(); + let mut diagnostics = Vec::new(); + if snapshot.ready_state != "complete" { + diagnostics.push(format!("document.readyState={}", snapshot.ready_state)); + } + let missing_text = expected_text + .iter() + .filter(|item| !item.found) + .map(|item| item.text.as_str()) + .collect::>(); + if !missing_text.is_empty() { + diagnostics.push(format!("缺少可见文本:{}", missing_text.join("、"))); + } + if input.fail_on_console_error && !capture.console_errors.is_empty() { + diagnostics.push(format!("console error {} 条", capture.console_errors.len())); + } + if !capture.exceptions.is_empty() { + diagnostics.push(format!("未捕获异常 {} 条", capture.exceptions.len())); + } + let fatal_request_count = capture + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(); + if fatal_request_count > 0 { + diagnostics.push(format!("失败请求 {} 条", fatal_request_count)); + } + if !same_preview_origin(&snapshot.final_url, preview_url) { + diagnostics.push("页面最终 URL 已离开当前预览 origin".to_string()); + } + let canvases = snapshot + .canvases + .into_iter() + .map(BrowserCanvasSnapshot::into_evidence) + .collect::>(); + if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) { + diagnostics.push(diagnostic.to_string()); + } + + Ok(BrowserViewportValidationOutcome { + result: BrowserViewportValidationResult { + viewport, + width, + height, + final_url: sanitize_url(&snapshot.final_url), + title: truncate_chars(&snapshot.title, 512), + ready_state: snapshot.ready_state, + visible_text_summary: snapshot.visible_text_summary, + visible_text_character_count: snapshot.visible_text_character_count, + dom_character_count: snapshot.dom_character_count, + expected_text, + console_errors: capture.console_errors, + console_warnings: capture.console_warnings, + exceptions: capture.exceptions, + failed_requests: capture.failed_requests, + canvases, + blocked_popup_count: snapshot.blocked_popup_count, + blocked_dialog_count: capture.blocked_dialog_count, + blocked_download_count: snapshot.blocked_download_count, + blocked_permission_count: snapshot.blocked_permission_count, + blocked_service_worker_count: snapshot.blocked_service_worker_count, + screenshot_path, + passed: diagnostics.is_empty(), + diagnostics, + }, + playtest, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/discovery.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/discovery.rs new file mode 100644 index 000000000..fce32437c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/discovery.rs @@ -0,0 +1,215 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::model::{DiscoveredBrowser, DiscoveredBrowserKind}; + +pub fn discover_chrome_or_edge() -> Result { + let mut seen = HashSet::new(); + for (path, kind) in system_browser_candidates() { + if !path.is_absolute() { + continue; + } + let canonical = path.canonicalize().unwrap_or(path); + if seen.insert(canonical.clone()) && is_executable_file(&canonical) { + return Ok(DiscoveredBrowser { + kind, + executable_path: canonical, + }); + } + } + Err("未发现可用的 Google Chrome、Chromium 或 Microsoft Edge".to_string()) +} + +pub(super) fn system_browser_candidates() -> Vec<(PathBuf, DiscoveredBrowserKind)> { + let mut candidates = Vec::new(); + append_platform_candidates(&mut candidates); + candidates +} + +#[cfg(target_os = "linux")] +fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) { + candidates.extend([ + ( + PathBuf::from("/opt/google/chrome/chrome"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/opt/google/chrome/google-chrome"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/usr/bin/google-chrome-stable"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/usr/bin/google-chrome"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/usr/bin/chromium"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/usr/bin/chromium-browser"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/usr/lib/chromium/chromium"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/usr/lib/chromium-browser/chromium-browser"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/opt/microsoft/msedge/msedge"), + DiscoveredBrowserKind::Edge, + ), + ( + PathBuf::from("/usr/bin/microsoft-edge-stable"), + DiscoveredBrowserKind::Edge, + ), + ( + PathBuf::from("/usr/bin/microsoft-edge"), + DiscoveredBrowserKind::Edge, + ), + ]); +} + +#[cfg(target_os = "macos")] +fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) { + candidates.extend([ + ( + PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"), + DiscoveredBrowserKind::Chrome, + ), + ( + PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), + DiscoveredBrowserKind::Edge, + ), + ]); +} + +#[cfg(target_os = "windows")] +fn append_platform_candidates(candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) { + for folder_id in [ + &FOLDER_ID_PROGRAM_FILES, + &FOLDER_ID_PROGRAM_FILES_X86, + &FOLDER_ID_LOCAL_APP_DATA, + ] { + let Some(root) = windows_known_folder_path(folder_id) else { + continue; + }; + candidates.push(( + root.join("Google/Chrome/Application/chrome.exe"), + DiscoveredBrowserKind::Chrome, + )); + candidates.push(( + root.join("Chromium/Application/chrome.exe"), + DiscoveredBrowserKind::Chrome, + )); + candidates.push(( + root.join("Microsoft/Edge/Application/msedge.exe"), + DiscoveredBrowserKind::Edge, + )); + } +} + +#[cfg(target_os = "windows")] +#[repr(C)] +struct WindowsGuid { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], +} + +#[cfg(target_os = "windows")] +const FOLDER_ID_PROGRAM_FILES: WindowsGuid = WindowsGuid { + data1: 0x905e63b6, + data2: 0xc1bf, + data3: 0x494e, + data4: [0xb2, 0x9c, 0x65, 0xb7, 0x32, 0xd3, 0xd2, 0x1a], +}; + +#[cfg(target_os = "windows")] +const FOLDER_ID_PROGRAM_FILES_X86: WindowsGuid = WindowsGuid { + data1: 0x7c5a40ef, + data2: 0xa0fb, + data3: 0x4bfc, + data4: [0x87, 0x4a, 0xc0, 0xf2, 0xe0, 0xb9, 0xfa, 0x8e], +}; + +#[cfg(target_os = "windows")] +const FOLDER_ID_LOCAL_APP_DATA: WindowsGuid = WindowsGuid { + data1: 0xf1b32785, + data2: 0x6fba, + data3: 0x4fcf, + data4: [0x9d, 0x55, 0x7b, 0x8e, 0x7f, 0x15, 0x70, 0x91], +}; + +#[cfg(target_os = "windows")] +#[link(name = "shell32")] +extern "system" { + fn SHGetKnownFolderPath( + folder_id: *const WindowsGuid, + flags: u32, + token: *mut std::ffi::c_void, + path: *mut *mut u16, + ) -> i32; +} + +#[cfg(target_os = "windows")] +#[link(name = "ole32")] +extern "system" { + fn CoTaskMemFree(value: *mut std::ffi::c_void); +} + +#[cfg(target_os = "windows")] +fn windows_known_folder_path(folder_id: &WindowsGuid) -> Option { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::ptr; + use std::slice; + + let mut raw_path = ptr::null_mut(); + let result = unsafe { SHGetKnownFolderPath(folder_id, 0, ptr::null_mut(), &mut raw_path) }; + if result < 0 || raw_path.is_null() { + return None; + } + let mut length = 0; + while unsafe { *raw_path.add(length) } != 0 { + length += 1; + } + let path = PathBuf::from(OsString::from_wide(unsafe { + slice::from_raw_parts(raw_path, length) + })); + unsafe { CoTaskMemFree(raw_path.cast()) }; + path.is_absolute().then_some(path) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn append_platform_candidates(_candidates: &mut Vec<(PathBuf, DiscoveredBrowserKind)>) {} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/evidence.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/evidence.rs new file mode 100644 index 000000000..080606981 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/evidence.rs @@ -0,0 +1,99 @@ +use std::fs; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tempfile::NamedTempFile; + +use super::model::BrowserValidationResult; + +pub(super) fn browser_validation_result_for_report( + result: &BrowserValidationResult, +) -> Result { + let evidence_root = &result.evidence.root; + let expected_report_path = evidence_root.join("validation.json"); + if result.evidence.report_path != expected_report_path { + return Err("浏览器验证报告路径与证据目录不匹配".to_string()); + } + + let mut persisted = result.clone(); + persisted.evidence.root = PathBuf::from("."); + persisted.evidence.report_path = PathBuf::from("validation.json"); + for viewport in &mut persisted.viewport_results { + let relative = viewport + .screenshot_path + .strip_prefix(evidence_root) + .map_err(|_| "浏览器验证截图路径不在证据目录内".to_string())?; + if relative.as_os_str().is_empty() || relative.components().count() != 1 { + return Err("浏览器验证截图路径不是证据目录内的直接文件".to_string()); + } + viewport.screenshot_path = relative.to_path_buf(); + } + Ok(persisted) +} +pub(super) fn validate_evidence_path(path: &Path) -> Result<(), String> { + if !path.is_absolute() || path.parent().is_none() { + return Err("evidenceRoot 必须是非根目录的绝对路径".to_string()); + } + if path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::CurDir)) + { + return Err("evidenceRoot 不能包含 . 或 ..".to_string()); + } + Ok(()) +} + +pub(super) fn prepare_evidence_root(path: &Path) -> Result<(), String> { + validate_evidence_path(path)?; + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string()); + } + } else { + fs::create_dir_all(path) + .map_err(|error| format!("创建浏览器证据目录失败:{}: {error}", path.display()))?; + } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取浏览器证据目录失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string()); + } + Ok(()) +} +pub(super) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("证据文件缺少父目录:{}", path.display()))?; + let mut temporary = + NamedTempFile::new_in(parent).map_err(|error| format!("创建证据临时文件失败:{error}"))?; + temporary + .write_all(bytes) + .map_err(|error| format!("写入证据临时文件失败:{error}"))?; + temporary + .as_file() + .sync_all() + .map_err(|error| format!("同步证据临时文件失败:{error}"))?; + temporary + .persist(path) + .map_err(|error| format!("保存证据文件失败:{}: {}", path.display(), error.error))?; + Ok(()) +} + +pub(super) fn write_json_report( + path: &Path, + result: &BrowserValidationResult, +) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(result) + .map_err(|error| format!("序列化浏览器验证报告失败:{error}"))?; + write_atomic(path, &bytes) +} + +pub(super) fn unix_time_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/model.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/model.rs new file mode 100644 index 000000000..382f3b2e4 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/model.rs @@ -0,0 +1,269 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Deserializer, Serialize}; + +pub(super) const RESULT_SCHEMA_VERSION: &str = "browser-validation.v1"; +pub(super) const DEFAULT_SETTLE_MS: u64 = 800; +pub(super) const MAX_SETTLE_MS: u64 = 30_000; +pub(super) const MAX_EXPECTED_TEXT_ITEMS: usize = 32; +pub(super) const MAX_EXPECTED_TEXT_CHARS: usize = 512; +pub(super) const MAX_URL_CHARS: usize = 2_048; +pub(super) const BROWSER_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BrowserValidationViewport { + Desktop, + Mobile, +} + +pub(super) const REQUIRED_VIEWPORTS: [BrowserValidationViewport; 2] = [ + BrowserValidationViewport::Desktop, + BrowserValidationViewport::Mobile, +]; + +impl BrowserValidationViewport { + pub(super) fn dimensions(self) -> (u32, u32, bool) { + match self { + Self::Desktop => (1280, 720, false), + Self::Mobile => (390, 844, true), + } + } + + pub(super) fn file_stem(self) -> &'static str { + match self { + Self::Desktop => "desktop", + Self::Mobile => "mobile", + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BrowserPlaytestScenario { + GenericV1, + LaneDefenseV1, +} + +impl BrowserPlaytestScenario { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::GenericV1 => "generic-v1", + Self::LaneDefenseV1 => "lane-defense-v1", + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BrowserValidationInput { + pub url: String, + #[serde(deserialize_with = "deserialize_fixed_viewports")] + pub viewports: Vec, + #[serde(default)] + pub expected_text: Vec, + #[serde(default = "default_settle_ms")] + pub settle_ms: u64, + #[serde(default = "default_fail_on_console_error")] + pub fail_on_console_error: bool, + #[serde(default)] + pub playtest_scenario: Option, + pub evidence_root: PathBuf, +} + +fn default_settle_ms() -> u64 { + DEFAULT_SETTLE_MS +} + +fn default_fail_on_console_error() -> bool { + true +} + +pub(super) fn validate_fixed_viewports( + viewports: &[BrowserValidationViewport], +) -> Result<(), String> { + if viewports.len() != REQUIRED_VIEWPORTS.len() { + return Err("viewports 必须且只能同时包含 desktop 和 mobile".to_string()); + } + let mut unique = HashSet::new(); + if viewports.iter().any(|viewport| !unique.insert(*viewport)) { + return Err("viewports 不能重复".to_string()); + } + if REQUIRED_VIEWPORTS + .iter() + .any(|required| !unique.contains(required)) + { + return Err("viewports 只能包含 desktop 和 mobile".to_string()); + } + Ok(()) +} + +fn deserialize_fixed_viewports<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let viewports = Vec::::deserialize(deserializer)?; + validate_fixed_viewports(&viewports).map_err(serde::de::Error::custom)?; + Ok(viewports) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DiscoveredBrowserKind { + Chrome, + Edge, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveredBrowser { + pub kind: DiscoveredBrowserKind, + pub executable_path: PathBuf, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserIdentity { + pub kind: DiscoveredBrowserKind, + pub product: String, + pub protocol_version: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserValidationEvidencePaths { + pub root: PathBuf, + pub report_path: PathBuf, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserExpectedTextMatch { + pub text: String, + pub found: bool, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BrowserPlaytestPhase { + Ready, + Playing, + Won, + Lost, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BrowserPlaytestAssertion { + pub name: String, + pub passed: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BrowserPlaytestResult { + pub scenario: BrowserPlaytestScenario, + pub scenario_fingerprint: String, + pub passed: bool, + pub initial_sequence: Option, + pub initial_phase: Option, + pub initial_level: Option, + pub final_sequence: Option, + pub final_phase: Option, + pub final_level: Option, + pub assertions: Vec, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserConsoleMessage { + pub level: String, + pub text: String, + pub source_url: Option, + pub line_number: Option, + pub column_number: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserException { + pub text: String, + pub source_url: Option, + pub line_number: u32, + pub column_number: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserFailedRequest { + pub url: String, + pub method: String, + pub resource_type: String, + pub error_text: String, + pub status_code: Option, + pub canceled: bool, + pub blocked_by_policy: bool, + pub fatal: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserCanvasProbe { + pub width: u32, + pub height: u32, + pub css_width: f64, + pub css_height: f64, + pub visible_area: f64, + pub sample_count: u32, + pub non_empty_pixel_count: u32, + pub non_empty: Option, + pub probe_error: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserViewportValidationResult { + pub viewport: BrowserValidationViewport, + pub width: u32, + pub height: u32, + pub final_url: String, + pub title: String, + pub ready_state: String, + pub visible_text_summary: String, + pub visible_text_character_count: usize, + pub dom_character_count: usize, + pub expected_text: Vec, + pub console_errors: Vec, + pub console_warnings: Vec, + pub exceptions: Vec, + pub failed_requests: Vec, + pub canvases: Vec, + pub blocked_popup_count: u32, + pub blocked_dialog_count: u32, + pub blocked_download_count: u32, + pub blocked_permission_count: u32, + pub blocked_service_worker_count: u32, + pub screenshot_path: PathBuf, + pub passed: bool, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserValidationResult { + pub schema_version: String, + pub url: String, + pub browser: BrowserIdentity, + pub passed: bool, + pub viewport_results: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub playtest: Option, + pub diagnostics: Vec, + pub evidence: BrowserValidationEvidencePaths, + pub completed_at_unix_ms: u64, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/network_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/network_policy.rs new file mode 100644 index 000000000..f4218fe60 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/network_policy.rs @@ -0,0 +1,143 @@ +use std::net::Ipv4Addr; + +use chromiumoxide::cdp::browser_protocol::network::ResourceType; +use url::{Host, Url}; + +use super::evidence::validate_evidence_path; +use super::model::{ + validate_fixed_viewports, BrowserValidationInput, MAX_EXPECTED_TEXT_CHARS, + MAX_EXPECTED_TEXT_ITEMS, MAX_SETTLE_MS, MAX_URL_CHARS, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PreviewRequestBlockReason { + CrossOrigin, + RedirectTarget, + WebSocketBeforeHandshake, +} + +impl PreviewRequestBlockReason { + pub(super) fn message(self) -> &'static str { + match self { + Self::CrossOrigin => "blocked by preview origin policy before request", + Self::RedirectTarget => "blocked cross-origin redirect before request", + Self::WebSocketBeforeHandshake => "blocked cross-origin WebSocket before handshake", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PreviewRequestDecision { + Allow, + Block(PreviewRequestBlockReason), +} + +pub(super) fn validate_input(input: &BrowserValidationInput) -> Result { + if input.url.chars().count() > MAX_URL_CHARS { + return Err("预览 URL 过长".to_string()); + } + let url = Url::parse(input.url.trim()).map_err(|error| format!("预览 URL 无效:{error}"))?; + if url.scheme() != "http" + || url.host() != Some(Host::Ipv4(Ipv4Addr::LOCALHOST)) + || url.port().is_none() + || url.port() == Some(0) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err("只允许带显式端口的 http://127.0.0.1 预览 URL".to_string()); + } + validate_fixed_viewports(&input.viewports)?; + if input.settle_ms > MAX_SETTLE_MS { + return Err(format!("settleMs 不能超过 {MAX_SETTLE_MS}")); + } + if input.expected_text.len() > MAX_EXPECTED_TEXT_ITEMS { + return Err(format!( + "expectedText 不能超过 {MAX_EXPECTED_TEXT_ITEMS} 项" + )); + } + for text in &input.expected_text { + let length = text.chars().count(); + if text.trim().is_empty() || length > MAX_EXPECTED_TEXT_CHARS { + return Err(format!( + "expectedText 每项必须非空且不超过 {MAX_EXPECTED_TEXT_CHARS} 字符" + )); + } + } + validate_evidence_path(&input.evidence_root)?; + Ok(url) +} + +pub(super) fn preview_proxy_bypass_list(origin: &Url) -> String { + let (Some(host), Some(port)) = (origin.host_str(), origin.port()) else { + return "<-loopback>".to_string(); + }; + format!("<-loopback>;http://{host}:{port};ws://{host}:{port}") +} + +pub(super) fn preview_request_decision( + raw: &str, + resource_type: &ResourceType, + redirected: bool, + origin: &Url, +) -> PreviewRequestDecision { + let allowed = if resource_type == &ResourceType::WebSocket { + websocket_url_allowed(raw, origin) + } else { + request_url_allowed(raw, origin) + }; + if allowed { + PreviewRequestDecision::Allow + } else if resource_type == &ResourceType::WebSocket { + PreviewRequestDecision::Block(PreviewRequestBlockReason::WebSocketBeforeHandshake) + } else if redirected { + PreviewRequestDecision::Block(PreviewRequestBlockReason::RedirectTarget) + } else { + PreviewRequestDecision::Block(PreviewRequestBlockReason::CrossOrigin) + } +} + +fn request_url_allowed(raw: &str, origin: &Url) -> bool { + if raw == "about:blank" || raw.starts_with("data:") { + return true; + } + if let Some(inner) = raw.strip_prefix("blob:") { + return Url::parse(inner) + .ok() + .map(|url| same_origin_url(&url, origin)) + .unwrap_or(false); + } + let Ok(url) = Url::parse(raw) else { + return false; + }; + match url.scheme() { + "http" => same_origin_url(&url, origin), + _ => false, + } +} + +fn websocket_url_allowed(raw: &str, origin: &Url) -> bool { + let Ok(url) = Url::parse(raw) else { + return false; + }; + origin.scheme() == "http" + && url.scheme() == "ws" + && url.host() == origin.host() + && url.port_or_known_default() == origin.port_or_known_default() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() +} + +pub(super) fn same_preview_origin(raw: &str, origin: &Url) -> bool { + Url::parse(raw) + .ok() + .map(|url| same_origin_url(&url, origin)) + .unwrap_or(false) +} + +fn same_origin_url(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() + && left.host() == right.host() + && left.port_or_known_default() == right.port_or_known_default() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs new file mode 100644 index 000000000..78a3e336b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/generic.rs @@ -0,0 +1,99 @@ +use chromiumoxide::Page; +use tokio::time::Instant; + +use super::{ + click_playtest_control, poll_playable_web_game_state, BrowserPlaytestPhase, + BrowserPlaytestResult, BrowserPlaytestScenario, PlayableWebGameState, + PLAYTEST_RESTART_SELECTOR, PLAYTEST_START_SELECTOR, +}; + +pub(super) async fn execute_generic_playtest( + page: &Page, + deadline: Instant, + result: &mut BrowserPlaytestResult, + initial: PlayableWebGameState, +) -> Result<(), String> { + click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?; + result.set_assertion("start-control-clicked", true); + let started = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::GenericV1, + deadline, + initial.sequence, + "start", + |state| { + matches!( + state.phase, + BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won + ) + }, + ) + .await?; + if let Some(state) = started.last_state.as_ref() { + result.record_final_state(state); + } + let start_sequence_advanced = started + .last_state + .as_ref() + .map(|state| state.sequence > initial.sequence) + .unwrap_or(false); + let start_phase_valid = started + .last_state + .as_ref() + .map(|state| { + matches!( + state.phase, + BrowserPlaytestPhase::Playing | BrowserPlaytestPhase::Won + ) + }) + .unwrap_or(false); + result.set_assertion("start-sequence-advanced", start_sequence_advanced); + result.set_assertion("start-phase-playing-or-won", start_phase_valid); + if !started.matched { + return Err("generic-v1 start 后状态未在总时限内推进".to_string()); + } + let started_state = started + .last_state + .ok_or_else(|| "generic-v1 start 后未读取到状态".to_string())?; + + click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; + result.set_assertion("restart-control-clicked", true); + let restarted = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::GenericV1, + deadline, + started_state.sequence, + "restart", + |state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }, + ) + .await?; + if let Some(state) = restarted.last_state.as_ref() { + result.record_final_state(state); + } + let restart_sequence_advanced = restarted + .last_state + .as_ref() + .map(|state| state.sequence > started_state.sequence) + .unwrap_or(false); + let restart_phase_valid = restarted + .last_state + .as_ref() + .map(|state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }) + .unwrap_or(false); + result.set_assertion("restart-sequence-advanced", restart_sequence_advanced); + result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid); + if !restarted.matched { + return Err("generic-v1 restart 后状态未在总时限内推进".to_string()); + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/lane_defense.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/lane_defense.rs new file mode 100644 index 000000000..ad42c895c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/lane_defense.rs @@ -0,0 +1,445 @@ +use std::collections::{HashMap, HashSet}; + +use chromiumoxide::Page; +use tokio::time::Instant; + +use super::{ + click_playtest_control, poll_playable_web_game_state, required_playable_finite_number, + validate_lane_value, BrowserPlaytestPhase, BrowserPlaytestResult, BrowserPlaytestScenario, + PlayableEnemyState, PlayableWebGameState, MAX_PLAYABLE_GAME_COLLECTION_ITEMS, + MAX_PLAYABLE_GAME_ID_CHARS, PLAYTEST_DEFENDER_OPTION_SELECTOR, PLAYTEST_LANE_CELL_SELECTOR, + PLAYTEST_NEXT_LEVEL_SELECTOR, PLAYTEST_RESTART_SELECTOR, PLAYTEST_SPEED_UP_SELECTOR, + PLAYTEST_START_SELECTOR, +}; + +pub(in crate::browser) struct LaneBattleProgress { + pub(in crate::browser) baseline_sequence: u64, + pub(in crate::browser) previous_state: PlayableWebGameState, + pub(in crate::browser) sequence_advanced: bool, + pub(in crate::browser) sequence_monotonic: bool, + pub(in crate::browser) enemy_position_changed: bool, + pub(in crate::browser) enemy_health_decreased: bool, +} + +impl LaneBattleProgress { + pub(in crate::browser) fn new(initial: PlayableWebGameState) -> Self { + Self { + baseline_sequence: initial.sequence, + previous_state: initial, + sequence_advanced: false, + sequence_monotonic: true, + enemy_position_changed: false, + enemy_health_decreased: false, + } + } + + pub(in crate::browser) fn observe(&mut self, state: &PlayableWebGameState) { + self.sequence_advanced |= state.sequence > self.baseline_sequence; + self.sequence_monotonic &= state.sequence >= self.previous_state.sequence; + let (position_changed, health_decreased) = + lane_enemy_state_changes(&self.previous_state, state); + self.enemy_position_changed |= position_changed; + self.enemy_health_decreased |= health_decreased; + self.previous_state = state.clone(); + } + + pub(in crate::browser) fn completed(&self, state: &PlayableWebGameState) -> bool { + self.sequence_advanced + && self.sequence_monotonic + && self.enemy_position_changed + && self.enemy_health_decreased + && state.phase == BrowserPlaytestPhase::Won + } +} + +pub(super) fn parse_lane_defense_playable_state( + object: &serde_json::Map, +) -> Result< + ( + Option, + Option, + Option>, + ), + String, +> { + let selected_defender_id = match object.get("selectedDefenderId") { + Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(value)) + if !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS => + { + Some(value.clone()) + } + Some(_) => { + return Err( + "lane-defense 状态 selectedDefenderId 必须是 null 或非空字符串".to_string(), + ); + } + None => return Err("lane-defense 状态缺少 selectedDefenderId".to_string()), + }; + let defenders = object + .get("defenders") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "lane-defense 状态 defenders 必须是数组".to_string())?; + if defenders.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS { + return Err("lane-defense 状态 defenders 超过数量上限".to_string()); + } + let enemy_values = object + .get("enemies") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "lane-defense 状态 enemies 必须是数组".to_string())?; + if enemy_values.len() > MAX_PLAYABLE_GAME_COLLECTION_ITEMS { + return Err("lane-defense 状态 enemies 超过数量上限".to_string()); + } + let mut enemy_ids = HashSet::with_capacity(enemy_values.len()); + let mut enemies = Vec::with_capacity(enemy_values.len()); + for enemy_value in enemy_values { + let enemy = enemy_value + .as_object() + .ok_or_else(|| "lane-defense 状态 enemy 必须是 object".to_string())?; + let id = enemy + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|value| { + !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS + }) + .ok_or_else(|| "lane-defense 状态 enemy.id 必须是有界非空字符串".to_string())?; + if !enemy_ids.insert(id.to_string()) { + return Err("lane-defense 状态 enemy.id 不能重复".to_string()); + } + validate_lane_value( + enemy + .get("lane") + .ok_or_else(|| "lane-defense 状态 enemy 缺少 lane".to_string())?, + )?; + let position = required_playable_finite_number(enemy, "position")?; + let health = required_playable_finite_number(enemy, "health")?; + let max_health = required_playable_finite_number(enemy, "maxHealth")?; + if health < 0.0 || max_health <= 0.0 || health > max_health { + return Err("lane-defense 状态 enemy health/maxHealth 边界无效".to_string()); + } + enemies.push(PlayableEnemyState { + id: id.to_string(), + position, + health, + }); + } + Ok((selected_defender_id, Some(defenders.len()), Some(enemies))) +} + +pub(super) async fn execute_lane_defense_playtest( + page: &Page, + deadline: Instant, + result: &mut BrowserPlaytestResult, + initial: PlayableWebGameState, +) -> Result<(), String> { + let initial_phase_ready = initial.phase == BrowserPlaytestPhase::Ready; + let level_positive = initial.level > 0; + result.set_assertion("initial-phase-ready", initial_phase_ready); + result.set_assertion("level-positive", level_positive); + if !initial_phase_ready { + return Err("lane-defense-v1 初始状态必须为 ready".to_string()); + } + if !level_positive { + return Err("lane-defense-v1 初始 level 必须大于 0".to_string()); + } + + click_playtest_control(page, PLAYTEST_START_SELECTOR, "start", deadline).await?; + result.set_assertion("start-control-visible", true); + result.set_assertion("start-control-enabled", true); + result.set_assertion("start-control-clicked", true); + let started = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + initial.sequence, + "start", + |state| state.phase == BrowserPlaytestPhase::Playing, + ) + .await?; + if let Some(state) = started.last_state.as_ref() { + result.record_final_state(state); + } + let start_sequence_advanced = started + .last_state + .as_ref() + .map(|state| state.sequence > initial.sequence) + .unwrap_or(false); + result.set_assertion("start-sequence-advanced", start_sequence_advanced); + result.set_assertion("start-phase-playing", started.matched); + if !started.matched { + return Err("lane-defense-v1 start 后 sequence 未推进或未进入 playing".to_string()); + } + let started_state = started + .last_state + .ok_or_else(|| "lane-defense-v1 start 后未读取到状态".to_string())?; + + click_playtest_control( + page, + PLAYTEST_DEFENDER_OPTION_SELECTOR, + "defender-option", + deadline, + ) + .await?; + result.set_assertion("defender-option-control-visible", true); + result.set_assertion("defender-option-control-enabled", true); + result.set_assertion("defender-option-control-clicked", true); + let selected = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + started_state.sequence, + "defender-option", + |state| state.selected_defender_id.is_some(), + ) + .await?; + if let Some(state) = selected.last_state.as_ref() { + result.record_final_state(state); + } + let defender_selection_sequence_advanced = selected + .last_state + .as_ref() + .map(|state| state.sequence > started_state.sequence) + .unwrap_or(false); + result.set_assertion( + "defender-selection-sequence-advanced", + defender_selection_sequence_advanced, + ); + result.set_assertion("defender-selection-recorded", selected.matched); + if !selected.matched { + return Err( + "lane-defense-v1 defender-option 后 sequence 未推进或未记录选择状态".to_string(), + ); + } + let selected_state = selected + .last_state + .ok_or_else(|| "lane-defense-v1 选择后未读取到状态".to_string())?; + let defender_count_before_placement = selected_state + .defender_count + .ok_or_else(|| "lane-defense-v1 defenders 状态缺失".to_string())?; + + click_playtest_control(page, PLAYTEST_LANE_CELL_SELECTOR, "lane-cell", deadline).await?; + result.set_assertion("lane-cell-control-visible", true); + result.set_assertion("lane-cell-control-enabled", true); + result.set_assertion("lane-cell-control-clicked", true); + let placed = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + selected_state.sequence, + "lane-cell", + |state| { + state + .defender_count + .map(|count| count > defender_count_before_placement) + .unwrap_or(false) + }, + ) + .await?; + if let Some(state) = placed.last_state.as_ref() { + result.record_final_state(state); + } + let defender_placement_sequence_advanced = placed + .last_state + .as_ref() + .map(|state| state.sequence > selected_state.sequence) + .unwrap_or(false); + result.set_assertion( + "defender-placement-sequence-advanced", + defender_placement_sequence_advanced, + ); + result.set_assertion("defender-count-increased", placed.matched); + if !placed.matched { + return Err( + "lane-defense-v1 lane-cell 后 sequence 未推进或 defender 数量未增加".to_string(), + ); + } + let mut combat_state = placed + .last_state + .ok_or_else(|| "lane-defense-v1 放置后未读取到状态".to_string())?; + let mut enemies_present = combat_state + .enemies + .as_ref() + .map(|enemies| !enemies.is_empty()) + .unwrap_or(false); + if !enemies_present { + let enemies_ready = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + combat_state.sequence, + "enemy-spawn", + |state| { + state + .enemies + .as_ref() + .map(|enemies| !enemies.is_empty()) + .unwrap_or(false) + }, + ) + .await?; + if let Some(state) = enemies_ready.last_state.as_ref() { + result.record_final_state(state); + } + enemies_present = enemies_ready.matched; + if let Some(state) = enemies_ready.last_state { + combat_state = state; + } + } + result.set_assertion("enemies-present-after-placement", enemies_present); + if !enemies_present { + return Err("lane-defense-v1 放置后没有可观察 enemy".to_string()); + } + + click_playtest_control(page, PLAYTEST_SPEED_UP_SELECTOR, "speed-up", deadline).await?; + result.set_assertion("speed-up-control-visible", true); + result.set_assertion("speed-up-control-enabled", true); + result.set_assertion("speed-up-control-clicked", true); + let battle_baseline_sequence = combat_state.sequence; + let mut battle_progress = LaneBattleProgress::new(combat_state); + let completed = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + battle_baseline_sequence, + "speed-up/battle", + |state| { + battle_progress.observe(state); + battle_progress.completed(state) + }, + ) + .await?; + if let Some(state) = completed.last_state.as_ref() { + result.record_final_state(state); + } + let won = completed + .last_state + .as_ref() + .map(|state| state.phase == BrowserPlaytestPhase::Won) + .unwrap_or(false); + result.set_assertion( + "battle-sequence-advanced", + battle_progress.sequence_advanced, + ); + result.set_assertion( + "battle-sequence-monotonic", + battle_progress.sequence_monotonic, + ); + result.set_assertion( + "enemy-position-changed", + battle_progress.enemy_position_changed, + ); + result.set_assertion( + "enemy-health-decreased", + battle_progress.enemy_health_decreased, + ); + result.set_assertion("phase-won", won); + if !completed.matched { + return Err("lane-defense-v1 未在总时限内观察到战斗推进并获胜".to_string()); + } + let won_state = completed + .last_state + .ok_or_else(|| "lane-defense-v1 获胜后未读取到状态".to_string())?; + + click_playtest_control(page, PLAYTEST_NEXT_LEVEL_SELECTOR, "next-level", deadline).await?; + result.set_assertion("next-level-control-visible", true); + result.set_assertion("next-level-control-enabled", true); + result.set_assertion("next-level-control-clicked", true); + let next_level = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + won_state.sequence, + "next-level", + |state| state.level > won_state.level, + ) + .await?; + if let Some(state) = next_level.last_state.as_ref() { + result.record_final_state(state); + } + let next_level_sequence_advanced = next_level + .last_state + .as_ref() + .map(|state| state.sequence > won_state.sequence) + .unwrap_or(false); + result.set_assertion("next-level-sequence-advanced", next_level_sequence_advanced); + result.set_assertion("level-increased", next_level.matched); + if !next_level.matched { + return Err("lane-defense-v1 next-level 后 sequence 未推进或 level 未增加".to_string()); + } + let next_level_state = next_level + .last_state + .ok_or_else(|| "lane-defense-v1 next-level 后未读取到状态".to_string())?; + + click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?; + result.set_assertion("restart-control-visible", true); + result.set_assertion("restart-control-enabled", true); + result.set_assertion("restart-control-clicked", true); + let restarted = poll_playable_web_game_state( + page, + BrowserPlaytestScenario::LaneDefenseV1, + deadline, + next_level_state.sequence, + "restart", + |state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }, + ) + .await?; + if let Some(state) = restarted.last_state.as_ref() { + result.record_final_state(state); + } + let restart_sequence_advanced = restarted + .last_state + .as_ref() + .map(|state| state.sequence > next_level_state.sequence) + .unwrap_or(false); + let restart_phase_valid = restarted + .last_state + .as_ref() + .map(|state| { + matches!( + state.phase, + BrowserPlaytestPhase::Ready | BrowserPlaytestPhase::Playing + ) + }) + .unwrap_or(false); + result.set_assertion("restart-sequence-advanced", restart_sequence_advanced); + result.set_assertion("restart-phase-ready-or-playing", restart_phase_valid); + if !restarted.matched { + return Err("lane-defense-v1 restart 后状态未在总时限内推进".to_string()); + } + Ok(()) +} + +pub(in crate::browser) fn lane_enemy_state_changes( + previous: &PlayableWebGameState, + current: &PlayableWebGameState, +) -> (bool, bool) { + let Some(previous_enemies) = previous.enemies.as_ref() else { + return (false, false); + }; + let Some(current_enemies) = current.enemies.as_ref() else { + return (false, false); + }; + let current_by_id = current_enemies + .iter() + .map(|enemy| (enemy.id.as_str(), enemy)) + .collect::>(); + let mut position_changed = false; + let mut health_decreased = false; + for previous_enemy in previous_enemies { + match current_by_id.get(previous_enemy.id.as_str()) { + Some(enemy) => { + position_changed |= enemy.position != previous_enemy.position; + health_decreased |= enemy.health < previous_enemy.health; + } + None => { + health_decreased |= previous_enemy.health > 0.0; + } + } + } + (position_changed, health_decreased) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs new file mode 100644 index 000000000..d477b1915 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -0,0 +1,612 @@ +use std::time::Duration; + +use chromiumoxide::Page; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tokio::time::Instant; + +use super::capture::truncate_chars; +use super::model::{ + BrowserPlaytestAssertion, BrowserPlaytestPhase, BrowserPlaytestResult, BrowserPlaytestScenario, +}; + +mod generic; +mod lane_defense; + +pub(super) use lane_defense::{lane_enemy_state_changes, LaneBattleProgress}; + +pub(super) const PLAYABLE_GAME_STATE_SCHEMA_VERSION: &str = "playable-web-game-state.v1"; +const MAX_PLAYABLE_GAME_STATE_JSON_CHARS: usize = 128 * 1024; +const MAX_PLAYABLE_GAME_COLLECTION_ITEMS: usize = 4_096; +const MAX_PLAYABLE_GAME_ID_CHARS: usize = 256; +const PLAYTEST_TOTAL_TIMEOUT: Duration = Duration::from_secs(30); +const PLAYTEST_POLL_INTERVAL: Duration = Duration::from_millis(50); + +const GENERIC_PLAYTEST_ASSERTIONS: &[&str] = &[ + "state-surface-valid", + "start-control-clicked", + "start-sequence-advanced", + "start-phase-playing-or-won", + "restart-control-clicked", + "restart-sequence-advanced", + "restart-phase-ready-or-playing", +]; + +const LANE_DEFENSE_PLAYTEST_ASSERTIONS: &[&str] = &[ + "state-surface-valid", + "initial-phase-ready", + "level-positive", + "start-control-visible", + "start-control-enabled", + "start-control-clicked", + "start-sequence-advanced", + "start-phase-playing", + "defender-option-control-visible", + "defender-option-control-enabled", + "defender-option-control-clicked", + "defender-selection-sequence-advanced", + "defender-selection-recorded", + "lane-cell-control-visible", + "lane-cell-control-enabled", + "lane-cell-control-clicked", + "defender-placement-sequence-advanced", + "defender-count-increased", + "enemies-present-after-placement", + "speed-up-control-visible", + "speed-up-control-enabled", + "speed-up-control-clicked", + "battle-sequence-advanced", + "battle-sequence-monotonic", + "enemy-position-changed", + "enemy-health-decreased", + "phase-won", + "next-level-control-visible", + "next-level-control-enabled", + "next-level-control-clicked", + "next-level-sequence-advanced", + "level-increased", + "restart-control-visible", + "restart-control-enabled", + "restart-control-clicked", + "restart-sequence-advanced", + "restart-phase-ready-or-playing", +]; + +#[derive(Clone, Debug)] +pub(super) struct PlayableWebGameState { + pub(super) sequence: u64, + pub(super) phase: BrowserPlaytestPhase, + pub(super) level: u64, + pub(super) selected_defender_id: Option, + pub(super) defender_count: Option, + pub(super) enemies: Option>, +} + +#[derive(Clone, Debug)] +pub(super) struct PlayableEnemyState { + pub(super) id: String, + pub(super) position: f64, + pub(super) health: f64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlayableStateSurfaceRead { + status: String, + content_length: usize, + content: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlaytestControlProbe { + is_html_element: bool, + visible: bool, + disabled: bool, +} + +pub(super) struct PlaytestPollOutcome { + pub(super) matched: bool, + pub(super) last_state: Option, +} + +impl BrowserPlaytestScenario { + pub(super) fn assertion_names(self) -> &'static [&'static str] { + match self { + Self::GenericV1 => GENERIC_PLAYTEST_ASSERTIONS, + Self::LaneDefenseV1 => LANE_DEFENSE_PLAYTEST_ASSERTIONS, + } + } +} + +impl BrowserPlaytestResult { + fn pending(scenario: BrowserPlaytestScenario) -> Self { + Self { + scenario, + scenario_fingerprint: browser_playtest_scenario_fingerprint(scenario), + passed: false, + initial_sequence: None, + initial_phase: None, + initial_level: None, + final_sequence: None, + final_phase: None, + final_level: None, + assertions: scenario + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: false, + }) + .collect(), + diagnostics: Vec::new(), + } + } + + fn record_initial_state(&mut self, state: &PlayableWebGameState) { + self.initial_sequence = Some(state.sequence); + self.initial_phase = Some(state.phase); + self.initial_level = Some(state.level); + self.record_final_state(state); + } + + pub(super) fn record_final_state(&mut self, state: &PlayableWebGameState) { + self.final_sequence = Some(state.sequence); + self.final_phase = Some(state.phase); + self.final_level = Some(state.level); + } + + pub(super) fn set_assertion(&mut self, name: &str, passed: bool) { + if let Some(assertion) = self + .assertions + .iter_mut() + .find(|assertion| assertion.name == name) + { + assertion.passed = passed; + } else { + self.push_diagnostic("内部试玩断言配置无效"); + } + } + + fn push_diagnostic(&mut self, diagnostic: impl Into) { + if self.diagnostics.len() >= 32 { + return; + } + let diagnostic = truncate_chars(&diagnostic.into(), 512); + if !self.diagnostics.contains(&diagnostic) { + self.diagnostics.push(diagnostic); + } + } + + fn finish(mut self) -> Self { + let failed_assertions = self + .assertions + .iter() + .filter(|assertion| !assertion.passed) + .map(|assertion| assertion.name.clone()) + .collect::>(); + if !failed_assertions.is_empty() { + self.push_diagnostic(format!( + "未通过固定试玩断言:{}", + failed_assertions.join("、") + )); + } + self.passed = browser_playtest_assertions_passed(&self.assertions, &self.diagnostics); + self + } +} + +pub(super) fn browser_playtest_assertions_passed( + assertions: &[BrowserPlaytestAssertion], + diagnostics: &[String], +) -> bool { + !assertions.is_empty() + && assertions.iter().all(|assertion| assertion.passed) + && diagnostics.is_empty() +} + +const PLAYABLE_STATE_CONTRACT_FINGERPRINT_MATERIAL: &str = concat!( + "surface=script#playable-web-game-state[type=application/json]\n", + "schemaVersion=playable-web-game-state.v1\n", + "base=sequence:u64,phase:ready|playing|won|lost,level:u64\n", + "lane=selectedDefenderId:null|string,defenders:array,", + "enemies:[id,lane,position,health,maxHealth],level>0\n", + "observation=action-sequence-strict,cross-observation-monotonic,", + "missing-enemy-health-zero" +); + +pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestScenario) -> String { + let mut hasher = Sha256::new(); + update_playtest_fingerprint_component(&mut hasher, "browser-playtest-scenario-fingerprint.v1"); + update_playtest_fingerprint_component(&mut hasher, scenario.as_str()); + update_playtest_fingerprint_component( + &mut hasher, + &format!( + concat!( + "viewport=desktop\n", + "click=chromiumoxide-element-mouse-input\n", + "control=unique-visible-enabled\n", + "totalTimeoutMs={}\npollIntervalMs={}" + ), + PLAYTEST_TOTAL_TIMEOUT.as_millis(), + PLAYTEST_POLL_INTERVAL.as_millis() + ), + ); + update_playtest_fingerprint_component( + &mut hasher, + PLAYABLE_STATE_CONTRACT_FINGERPRINT_MATERIAL, + ); + update_playtest_fingerprint_component(&mut hasher, READ_PLAYABLE_GAME_STATE_SCRIPT); + update_playtest_fingerprint_component(&mut hasher, PROBE_PLAYTEST_CONTROL_SCRIPT); + match scenario { + BrowserPlaytestScenario::GenericV1 => { + update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR); + update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR); + } + BrowserPlaytestScenario::LaneDefenseV1 => { + for selector in [ + PLAYTEST_START_SELECTOR, + PLAYTEST_DEFENDER_OPTION_SELECTOR, + PLAYTEST_LANE_CELL_SELECTOR, + PLAYTEST_SPEED_UP_SELECTOR, + PLAYTEST_NEXT_LEVEL_SELECTOR, + PLAYTEST_RESTART_SELECTOR, + ] { + update_playtest_fingerprint_component(&mut hasher, selector); + } + } + } + for assertion in scenario.assertion_names() { + update_playtest_fingerprint_component(&mut hasher, assertion); + } + format!("{:x}", hasher.finalize()) +} + +fn update_playtest_fingerprint_component(hasher: &mut Sha256, component: &str) { + hasher.update((component.len() as u64).to_be_bytes()); + hasher.update(component.as_bytes()); +} + +pub(super) const READ_PLAYABLE_GAME_STATE_SCRIPT: &str = r#"(() => { + const byId = document.getElementById('playable-web-game-state'); + const exactScripts = document.querySelectorAll('script#playable-web-game-state'); + if (!byId) { + return { status: 'missing', contentLength: 0, content: null }; + } + if (!(byId instanceof HTMLScriptElement) || exactScripts.length !== 1 || exactScripts[0] !== byId) { + return { status: 'invalid-element', contentLength: 0, content: null }; + } + if (byId.getAttribute('type') !== 'application/json') { + return { status: 'invalid-type', contentLength: 0, content: null }; + } + const content = String(byId.textContent || ''); + if (content.length > 131072) { + return { status: 'too-large', contentLength: content.length, content: null }; + } + return { status: 'ok', contentLength: content.length, content }; +})()"#; + +pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() { + const isHtmlElement = this instanceof HTMLElement; + if (!isHtmlElement) { + return JSON.stringify({ isHtmlElement: false, visible: false, disabled: true }); + } + + let stylesVisible = true; + let pointerBlocked = false; + for (let current = this; current instanceof HTMLElement; current = current.parentElement) { + const style = getComputedStyle(current); + const opacity = Number.parseFloat(style.opacity); + stylesVisible &&= !current.hidden + && style.display !== 'none' + && style.visibility !== 'hidden' + && style.visibility !== 'collapse' + && (Number.isNaN(opacity) || opacity > 0); + pointerBlocked ||= style.pointerEvents === 'none'; + } + + const rect = this.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const centerInViewport = centerX >= 0 + && centerY >= 0 + && centerX < window.innerWidth + && centerY < window.innerHeight; + const hit = centerInViewport ? document.elementFromPoint(centerX, centerY) : null; + const visible = this.isConnected + && stylesVisible + && rect.width > 0 + && rect.height > 0 + && this.getClientRects().length > 0 + && hit !== null + && (hit === this || this.contains(hit)); + const ariaDisabled = String(this.getAttribute('aria-disabled') || '').toLowerCase() === 'true'; + const disabled = this.matches(':disabled') + || this.hasAttribute('disabled') + || ariaDisabled + || pointerBlocked + || this.closest('[inert]') !== null; + return JSON.stringify({ isHtmlElement, visible, disabled }); +}"#; + +pub(super) const PLAYTEST_START_SELECTOR: &str = r#"[data-playtest-id="start"]"#; +pub(super) const PLAYTEST_RESTART_SELECTOR: &str = r#"[data-playtest-id="restart"]"#; +pub(super) const PLAYTEST_DEFENDER_OPTION_SELECTOR: &str = + r#"[data-playtest-id="defender-option"]"#; +pub(super) const PLAYTEST_LANE_CELL_SELECTOR: &str = r#"[data-playtest-id="lane-cell"]"#; +pub(super) const PLAYTEST_SPEED_UP_SELECTOR: &str = r#"[data-playtest-id="speed-up"]"#; +pub(super) const PLAYTEST_NEXT_LEVEL_SELECTOR: &str = r#"[data-playtest-id="next-level"]"#; + +pub(super) fn parse_playable_web_game_state( + content: &str, + scenario: BrowserPlaytestScenario, +) -> Result { + if content.chars().count() > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { + return Err("固定试玩状态 JSON 超过大小上限".to_string()); + } + let value = serde_json::from_str::(content).map_err(|error| { + format!( + "固定试玩状态 JSON 无效(第 {} 行,第 {} 列)", + error.line(), + error.column() + ) + })?; + let object = value + .as_object() + .ok_or_else(|| "固定试玩状态必须是 JSON object".to_string())?; + if object + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + != Some(PLAYABLE_GAME_STATE_SCHEMA_VERSION) + { + return Err("固定试玩状态 schemaVersion 无效".to_string()); + } + let sequence = required_playable_u64(object, "sequence")?; + let level = required_playable_u64(object, "level")?; + let phase = match object.get("phase").and_then(serde_json::Value::as_str) { + Some("ready") => BrowserPlaytestPhase::Ready, + Some("playing") => BrowserPlaytestPhase::Playing, + Some("won") => BrowserPlaytestPhase::Won, + Some("lost") => BrowserPlaytestPhase::Lost, + _ => return Err("固定试玩状态 phase 无效".to_string()), + }; + + let (selected_defender_id, defender_count, enemies) = match scenario { + BrowserPlaytestScenario::GenericV1 => (None, None, None), + BrowserPlaytestScenario::LaneDefenseV1 => { + lane_defense::parse_lane_defense_playable_state(object)? + } + }; + + Ok(PlayableWebGameState { + sequence, + phase, + level, + selected_defender_id, + defender_count, + enemies, + }) +} + +fn required_playable_u64( + object: &serde_json::Map, + field: &str, +) -> Result { + object + .get(field) + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| format!("固定试玩状态 {field} 必须是 u64")) +} + +pub(super) async fn run_desktop_playtest( + page: &Page, + scenario: BrowserPlaytestScenario, +) -> BrowserPlaytestResult { + let mut result = BrowserPlaytestResult::pending(scenario); + let deadline = Instant::now() + PLAYTEST_TOTAL_TIMEOUT; + let execution = tokio::time::timeout( + PLAYTEST_TOTAL_TIMEOUT, + execute_desktop_playtest(page, scenario, deadline, &mut result), + ) + .await; + match execution { + Ok(Ok(())) => {} + Ok(Err(error)) => result.push_diagnostic(error), + Err(_) => result.push_diagnostic("固定试玩超过总时间上限"), + } + result.finish() +} + +async fn execute_desktop_playtest( + page: &Page, + scenario: BrowserPlaytestScenario, + deadline: Instant, + result: &mut BrowserPlaytestResult, +) -> Result<(), String> { + let initial = read_playable_web_game_state(page, scenario, deadline).await?; + result.record_initial_state(&initial); + result.set_assertion("state-surface-valid", true); + match scenario { + BrowserPlaytestScenario::GenericV1 => { + generic::execute_generic_playtest(page, deadline, result, initial).await + } + BrowserPlaytestScenario::LaneDefenseV1 => { + lane_defense::execute_lane_defense_playtest(page, deadline, result, initial).await + } + } +} + +pub(super) async fn read_playable_web_game_state( + page: &Page, + scenario: BrowserPlaytestScenario, + deadline: Instant, +) -> Result { + let remaining = playtest_remaining(deadline)?; + let evaluated = tokio::time::timeout(remaining, page.evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT)) + .await + .map_err(|_| "读取固定试玩状态超时".to_string())? + .map_err(|_| "读取固定试玩状态失败".to_string())?; + let surface = evaluated + .into_value::() + .map_err(|_| "解析固定试玩状态面读取结果失败".to_string())?; + if surface.content_length > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { + return Err("固定试玩状态 JSON 超过大小上限".to_string()); + } + let content = match surface.status.as_str() { + "ok" => surface + .content + .ok_or_else(|| "固定试玩状态面缺少 JSON 正文".to_string())?, + "missing" => return Err("缺少固定试玩状态面".to_string()), + "invalid-element" => return Err("固定试玩状态面元素无效或不唯一".to_string()), + "invalid-type" => { + return Err("固定试玩状态面 type 必须是 application/json".to_string()); + } + "too-large" => return Err("固定试玩状态 JSON 超过大小上限".to_string()), + _ => return Err("固定试玩状态面读取状态无效".to_string()), + }; + parse_playable_web_game_state(&content, scenario) +} + +pub(super) async fn click_playtest_control( + page: &Page, + selector: &'static str, + action: &'static str, + deadline: Instant, +) -> Result<(), String> { + let remaining = playtest_remaining(deadline)?; + let mut elements = tokio::time::timeout(remaining, page.find_elements(selector)) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 不存在或查询失败"))?; + if elements.len() != 1 { + return Err(format!( + "固定试玩控件 {action} 必须唯一,实际数量为 {}", + elements.len() + )); + } + let element = elements + .pop() + .ok_or_else(|| format!("固定试玩控件 {action} 不存在"))?; + + let remaining = playtest_remaining(deadline)?; + tokio::time::timeout(remaining, element.scroll_into_view()) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 无法滚动到可见区域"))?; + + let remaining = playtest_remaining(deadline)?; + let evaluated = tokio::time::timeout( + remaining, + element.call_js_fn(PROBE_PLAYTEST_CONTROL_SCRIPT, false), + ) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态读取失败"))?; + let probe = evaluated + .result + .value + .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果缺失"))?; + let probe = probe + .as_str() + .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + let probe = serde_json::from_str::(probe) + .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + if !probe.is_html_element { + return Err(format!("固定试玩控件 {action} 必须是 HTMLElement")); + } + if !probe.visible { + return Err(format!("固定试玩控件 {action} 不可见")); + } + if probe.disabled { + return Err(format!("固定试玩控件 {action} 处于 disabled 状态")); + } + + let remaining = playtest_remaining(deadline)?; + tokio::time::timeout(remaining, element.click()) + .await + .map_err(|_| format!("固定试玩动作 {action} 超时"))? + .map_err(|_| format!("固定试玩控件 {action} 不可点击"))?; + Ok(()) +} + +pub(super) async fn poll_playable_web_game_state( + page: &Page, + scenario: BrowserPlaytestScenario, + deadline: Instant, + baseline_sequence: u64, + observation: &'static str, + mut predicate: F, +) -> Result +where + F: FnMut(&PlayableWebGameState) -> bool, +{ + let mut last_state = None; + let mut previous_sequence = baseline_sequence; + loop { + if Instant::now() >= deadline { + return Ok(PlaytestPollOutcome { + matched: false, + last_state, + }); + } + let state = read_playable_web_game_state(page, scenario, deadline).await?; + if state.sequence < previous_sequence { + return Err(format!( + "固定试玩 {observation} 观察到 sequence 从 {previous_sequence} 回退到 {}", + state.sequence + )); + } + previous_sequence = state.sequence; + let matched = state.sequence > baseline_sequence && predicate(&state); + last_state = Some(state); + if matched { + return Ok(PlaytestPollOutcome { + matched: true, + last_state, + }); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(PlaytestPollOutcome { + matched: false, + last_state, + }); + } + tokio::time::sleep(std::cmp::min(PLAYTEST_POLL_INTERVAL, remaining)).await; + } +} + +fn playtest_remaining(deadline: Instant) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + Err("固定试玩超过总时间上限".to_string()) + } else { + Ok(remaining) + } +} + +pub(super) fn validate_lane_value(value: &serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::String(value) + if !value.trim().is_empty() && value.chars().count() <= MAX_PLAYABLE_GAME_ID_CHARS => + { + Ok(()) + } + serde_json::Value::Number(value) if value.as_u64().is_some() => Ok(()), + _ => Err("lane-defense 状态 enemy.lane 必须是 u64 或有界非空字符串".to_string()), + } +} + +pub(super) fn required_playable_finite_number( + object: &serde_json::Map, + field: &str, +) -> Result { + let value = object + .get(field) + .and_then(serde_json::Value::as_f64) + .ok_or_else(|| format!("lane-defense 状态 enemy.{field} 必须是数值"))?; + if !value.is_finite() { + return Err(format!("lane-defense 状态 enemy.{field} 必须是有限数值")); + } + Ok(value) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs new file mode 100644 index 000000000..0223b16c9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -0,0 +1,103 @@ +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use chromiumoxide::browser::{Browser, BrowserConfig}; +use futures::StreamExt; +use tempfile::{Builder as TempDirBuilder, TempDir}; + +use super::cdp::run_browser_validation; +use super::discovery::discover_chrome_or_edge; +use super::evidence::{ + browser_validation_result_for_report, prepare_evidence_root, unix_time_ms, write_json_report, +}; +use super::model::{BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT}; +use super::network_policy::{preview_proxy_bypass_list, validate_input}; + +fn browser_process_temp_root() -> PathBuf { + #[cfg(unix)] + { + PathBuf::from("/tmp") + } + #[cfg(not(unix))] + { + std::env::temp_dir() + } +} + +pub(super) fn create_browser_process_temp_dir() -> Result { + TempDirBuilder::new() + .prefix("ga-browser-") + .tempdir_in(browser_process_temp_root()) + .map_err(|error| format!("创建浏览器临时目录失败:{error}")) +} + +pub async fn validate_local_preview_in_browser( + input: BrowserValidationInput, +) -> Result { + let preview_url = validate_input(&input)?; + prepare_evidence_root(&input.evidence_root)?; + let browser_executable = discover_chrome_or_edge()?; + let browser_temp = create_browser_process_temp_dir()?; + let profile_path = browser_temp.path().join("profile"); + fs::create_dir(&profile_path) + .map_err(|error| format!("创建浏览器临时 Profile 失败:{error}"))?; + let browser_temp_path = browser_temp.path().to_string_lossy().into_owned(); + let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); + + let config = BrowserConfig::builder() + .chrome_executable(&browser_executable.executable_path) + .user_data_dir(profile_path) + .env("TMPDIR", browser_temp_path) + .new_headless_mode() + .enable_request_intercept() + .disable_cache() + .disable_https_first() + .request_timeout(BROWSER_TIMEOUT) + .launch_timeout(BROWSER_TIMEOUT) + .window_size(1280, 720) + .arg(("proxy-server", "http://127.0.0.1:9")) + .arg(("proxy-bypass-list", proxy_bypass_list.as_str())) + .arg("block-new-web-contents") + .arg("deny-permission-prompts") + .arg("disable-notifications") + .arg("disable-service-worker") + .build() + .map_err(|error| format!("构建浏览器配置失败:{error}"))?; + + let (mut browser, mut handler) = tokio::time::timeout(BROWSER_TIMEOUT, Browser::launch(config)) + .await + .map_err(|_| "启动浏览器超时".to_string())? + .map_err(|error| format!("启动浏览器失败:{error}"))?; + let handler_task = tokio::spawn(async move { + while let Some(message) = handler.next().await { + if message.is_err() { + break; + } + } + }); + + let validation = + run_browser_validation(&browser, &browser_executable, &preview_url, &input).await; + + let close_result = browser + .close() + .await + .map_err(|error| format!("关闭浏览器失败:{error}")); + let wait_result = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; + handler_task.abort(); + let _ = handler_task.await; + drop(browser_temp); + + let mut result = validation?; + close_result?; + match wait_result { + Ok(Ok(_)) => {} + Ok(Err(error)) => return Err(format!("等待浏览器退出失败:{error}")), + Err(_) => return Err("等待浏览器退出超时".to_string()), + } + result.completed_at_unix_ms = unix_time_ms(); + let persisted_result = browser_validation_result_for_report(&result)?; + write_json_report(&result.evidence.report_path, &persisted_result)?; + Ok(result) +} 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 new file mode 100644 index 000000000..1c309fa5d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -0,0 +1,1169 @@ +use super::*; +use std::env; +use std::fs; +use std::net::Ipv4Addr; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::Duration; + +use chromiumoxide::cdp::browser_protocol::fetch::RequestStage; +use chromiumoxide::cdp::browser_protocol::network::ResourceType; +use url::Url; + +use super::capture::{ + canvas_validation_diagnostic, classify_canvas_pixel_probe, preview_fetch_enable_params, + BrowserCanvasSnapshot, +}; +use super::discovery::system_browser_candidates; +use super::evidence::browser_validation_result_for_report; +use super::model::{DEFAULT_SETTLE_MS, MAX_SETTLE_MS, REQUIRED_VIEWPORTS, RESULT_SCHEMA_VERSION}; +use super::network_policy::{ + preview_proxy_bypass_list, preview_request_decision, validate_input, PreviewRequestBlockReason, + PreviewRequestDecision, +}; +use super::playtest::{ + browser_playtest_assertions_passed, lane_enemy_state_changes, parse_playable_web_game_state, + LaneBattleProgress, PlayableEnemyState, PlayableWebGameState, + PLAYABLE_GAME_STATE_SCHEMA_VERSION, +}; +use super::process::create_browser_process_temp_dir; + +static PATH_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn valid_input() -> BrowserValidationInput { + BrowserValidationInput { + url: "http://127.0.0.1:34567/".to_string(), + viewports: vec![ + BrowserValidationViewport::Desktop, + BrowserValidationViewport::Mobile, + ], + expected_text: vec!["开始游戏".to_string()], + settle_ms: DEFAULT_SETTLE_MS, + fail_on_console_error: true, + playtest_scenario: None, + evidence_root: env::temp_dir().join("browser-validation-test-evidence"), + } +} + +fn canvas_snapshot( + visible_area: f64, + sample_count: u32, + non_empty_pixel_count: u32, + distinct_pixel_state_count: u32, +) -> BrowserCanvasSnapshot { + BrowserCanvasSnapshot { + width: 64, + height: 64, + css_width: 64.0, + css_height: 64.0, + visible_area, + sample_count, + non_empty_pixel_count, + distinct_pixel_state_count, + probe_error: None, + } +} + +fn lane_state( + sequence: u64, + phase: BrowserPlaytestPhase, + enemies: Vec, +) -> PlayableWebGameState { + PlayableWebGameState { + sequence, + phase, + level: 1, + selected_defender_id: None, + defender_count: Some(1), + enemies: Some(enemies), + } +} + +#[test] +fn validates_loopback_url_and_rejects_external_urls() { + assert!(validate_input(&valid_input()).is_ok()); + for url in [ + "https://127.0.0.1:34567/", + "http://localhost:34567/", + "http://127.0.0.1/", + "http://127.0.0.1:34567/#fragment", + "https://example.com/", + ] { + let mut input = valid_input(); + input.url = url.to_string(); + assert!(validate_input(&input).is_err(), "accepted {url}"); + } +} + +#[test] +fn validates_fixed_viewports_and_input_bounds() { + assert_eq!( + BrowserValidationViewport::Desktop.dimensions(), + (1280, 720, false) + ); + assert_eq!( + BrowserValidationViewport::Mobile.dimensions(), + (390, 844, true) + ); + let mut input = valid_input(); + input.viewports.clear(); + assert!(validate_input(&input).is_err()); + for viewports in [ + vec![BrowserValidationViewport::Desktop], + vec![BrowserValidationViewport::Mobile], + vec![ + BrowserValidationViewport::Desktop, + BrowserValidationViewport::Desktop, + ], + vec![ + BrowserValidationViewport::Mobile, + BrowserValidationViewport::Mobile, + ], + ] { + input.viewports = viewports; + assert!(validate_input(&input).is_err()); + } + input.viewports = vec![ + BrowserValidationViewport::Mobile, + BrowserValidationViewport::Desktop, + ]; + assert!(validate_input(&input).is_ok()); + input = valid_input(); + input.settle_ms = MAX_SETTLE_MS + 1; + assert!(validate_input(&input).is_err()); + input = valid_input(); + input.expected_text = vec![" ".to_string()]; + assert!(validate_input(&input).is_err()); + input = valid_input(); + input.evidence_root = PathBuf::from("relative/evidence"); + assert!(validate_input(&input).is_err()); +} + +#[test] +fn deserialization_requires_exactly_desktop_and_mobile_viewports() { + for viewports in [ + serde_json::json!(["desktop"]), + serde_json::json!(["mobile"]), + serde_json::json!(["desktop", "desktop"]), + serde_json::json!(["mobile", "mobile"]), + serde_json::json!(["desktop", "tablet"]), + serde_json::json!(["desktop", "mobile", "tablet"]), + ] { + let value = serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": viewports, + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + }); + assert!( + serde_json::from_value::(value).is_err(), + "accepted invalid viewports {viewports}" + ); + } + + let input = serde_json::from_value::(serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": ["mobile", "desktop"], + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + })) + .expect("deserialize both fixed viewports"); + assert_eq!( + input.viewports, + vec![ + BrowserValidationViewport::Mobile, + BrowserValidationViewport::Desktop + ] + ); + assert_eq!(input.settle_ms, DEFAULT_SETTLE_MS); + assert!(input.fail_on_console_error); + assert_eq!(input.playtest_scenario, None); +} + +#[test] +fn playtest_input_accepts_only_fixed_scenario_names_and_rejects_custom_controls() { + for scenario in ["generic-v1", "lane-defense-v1"] { + let input = serde_json::from_value::(serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": ["desktop", "mobile"], + "playtestScenario": scenario, + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + })) + .expect("deserialize fixed playtest scenario"); + assert!(input.playtest_scenario.is_some()); + } + + for scenario in [ + serde_json::json!("custom-v1"), + serde_json::json!({"scenario": "generic-v1", "selector": "#custom"}), + serde_json::json!({"scenario": "generic-v1", "script": "alert(1)"}), + serde_json::json!({"scenario": "generic-v1", "url": "https://example.test"}), + serde_json::json!({"scenario": "generic-v1", "headers": {"x-test": "1"}}), + serde_json::json!({"scenario": "generic-v1", "cookie": "session=test"}), + serde_json::json!({"scenario": "generic-v1", "actions": ["click"]}), + ] { + let value = serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": ["desktop", "mobile"], + "playtestScenario": scenario, + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + }); + assert!( + serde_json::from_value::(value).is_err(), + "accepted custom playtest scenario input {scenario}" + ); + } + + for forbidden_field in [ + "selector", + "script", + "playtestUrl", + "headers", + "cookie", + "actions", + ] { + let mut value = serde_json::json!({ + "url": "http://127.0.0.1:34567/", + "viewports": ["desktop", "mobile"], + "playtestScenario": "generic-v1", + "evidenceRoot": env::temp_dir().join("browser-validation-test-evidence") + }); + value[forbidden_field] = serde_json::json!("custom"); + assert!( + serde_json::from_value::(value).is_err(), + "accepted forbidden field {forbidden_field}" + ); + } +} + +#[test] +fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { + let generic = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::GenericV1); + let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); + + assert_eq!( + generic, + "dd700c57b0adb3148aecfe2ed839c2c3fa0df89be89b785bf016f4484ad3be46" + ); + assert_eq!( + lane, + "6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a" + ); + assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!(lane.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_eq!(generic.len(), 64); + assert_eq!(lane.len(), 64); + assert_ne!(generic, lane); +} + +#[test] +fn playable_state_accepts_all_fixed_phases_and_u64_boundaries() { + for phase in ["ready", "playing", "won", "lost"] { + let state = parse_playable_web_game_state( + &serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": u64::MAX, + "phase": phase, + "level": 0 + }) + .to_string(), + BrowserPlaytestScenario::GenericV1, + ) + .expect("parse valid generic state"); + assert_eq!(state.sequence, u64::MAX); + assert_eq!(state.level, 0); + } + + for invalid in [ + r#"{"schemaVersion":"playable-web-game-state.v0","sequence":0,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"paused","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":-1,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":1.5,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":18446744073709551616,"phase":"ready","level":0}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":-1}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":1.5}"#, + r#"{"schemaVersion":"playable-web-game-state.v1","sequence":0,"phase":"ready","level":18446744073709551616}"#, + ] { + assert!( + parse_playable_web_game_state(invalid, BrowserPlaytestScenario::GenericV1).is_err(), + "accepted invalid state {invalid}" + ); + } +} + +#[test] +fn lane_defense_state_requires_bounded_selection_defenders_and_enemy_metrics() { + let valid = serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 7, + "phase": "playing", + "level": 2, + "selectedDefenderId": null, + "defenders": [], + "enemies": [{ + "id": "enemy-1", + "lane": 0, + "position": -1.25, + "health": 5.5, + "maxHealth": 10 + }] + }); + let state = + parse_playable_web_game_state(&valid.to_string(), BrowserPlaytestScenario::LaneDefenseV1) + .expect("parse valid lane-defense state"); + assert_eq!(state.defender_count, Some(0)); + assert_eq!(state.enemies.as_ref().map(Vec::len), Some(1)); + + for invalid in [ + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "ready", "level": 0, + "defenders": [], "enemies": [] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "ready", "level": 0, + "selectedDefenderId": null, "defenders": {}, "enemies": [] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": -1, "position": 0, "health": 1, "maxHealth": 1}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": -1, "maxHealth": 1}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 2, "maxHealth": 1}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "position": 0, "health": 0, "maxHealth": 0}] + }), + serde_json::json!({ + "schemaVersion": PLAYABLE_GAME_STATE_SCHEMA_VERSION, + "sequence": 0, "phase": "playing", "level": 0, + "selectedDefenderId": "defender-1", "defenders": [], + "enemies": [{"id": "enemy-1", "lane": "top", "health": 1, "maxHealth": 1}] + }), + ] { + assert!( + parse_playable_web_game_state( + &invalid.to_string(), + BrowserPlaytestScenario::LaneDefenseV1, + ) + .is_err(), + "accepted invalid lane-defense state {invalid}" + ); + } +} + +#[test] +fn lane_enemy_disappearance_counts_as_health_reaching_zero() { + let previous = lane_state( + 10, + BrowserPlaytestPhase::Playing, + vec![PlayableEnemyState { + id: "enemy-1".to_string(), + position: 80.0, + health: 4.0, + }], + ); + let current = lane_state(11, BrowserPlaytestPhase::Won, Vec::new()); + + assert_eq!(lane_enemy_state_changes(&previous, ¤t), (false, true)); +} + +#[test] +fn lane_battle_progress_requires_monotonic_sequence_and_real_changes() { + let initial = lane_state( + 20, + BrowserPlaytestPhase::Playing, + vec![PlayableEnemyState { + id: "enemy-1".to_string(), + position: 100.0, + health: 10.0, + }], + ); + let moved = lane_state( + 21, + BrowserPlaytestPhase::Playing, + vec![PlayableEnemyState { + id: "enemy-1".to_string(), + position: 60.0, + health: 10.0, + }], + ); + let won = lane_state(22, BrowserPlaytestPhase::Won, Vec::new()); + let mut progress = LaneBattleProgress::new(initial.clone()); + progress.observe(&moved); + progress.observe(&won); + + assert!(progress.completed(&won)); + assert!(progress.sequence_advanced); + assert!(progress.sequence_monotonic); + assert!(progress.enemy_position_changed); + assert!(progress.enemy_health_decreased); + + let regressed = lane_state(19, BrowserPlaytestPhase::Won, Vec::new()); + let mut regressed_progress = LaneBattleProgress::new(initial); + regressed_progress.observe(&moved); + regressed_progress.observe(®ressed); + assert!(!regressed_progress.completed(®ressed)); + assert!(!regressed_progress.sequence_monotonic); +} + +#[test] +fn playtest_assertion_summary_requires_every_assertion_and_no_diagnostics() { + let mut assertions = BrowserPlaytestScenario::GenericV1 + .assertion_names() + .iter() + .map(|name| BrowserPlaytestAssertion { + name: (*name).to_string(), + passed: true, + }) + .collect::>(); + assert!(browser_playtest_assertions_passed(&assertions, &[])); + + assertions[0].passed = false; + assert!(!browser_playtest_assertions_passed(&assertions, &[])); + assertions[0].passed = true; + assert!(!browser_playtest_assertions_passed( + &assertions, + &["固定诊断".to_string()] + )); + assert!(!browser_playtest_assertions_passed(&[], &[])); +} + +#[test] +fn classifies_only_multiple_meaningful_canvas_pixel_states_as_non_empty() { + assert_eq!(classify_canvas_pixel_probe(0, 0, 0), None); + assert_eq!(classify_canvas_pixel_probe(4_096, 0, 1), Some(false)); + assert_eq!(classify_canvas_pixel_probe(4_096, 4_096, 1), Some(false)); + assert_eq!(classify_canvas_pixel_probe(4_096, 2_048, 2), Some(true)); + assert_eq!(classify_canvas_pixel_probe(4_096, 4_096, 2), Some(true)); + assert_eq!(classify_canvas_pixel_probe(4, 5, 2), Some(false)); + assert_eq!(classify_canvas_pixel_probe(4, 4, 5), Some(false)); +} + +#[test] +fn requires_a_visible_canvas_with_meaningful_pixel_states() { + let transparent = canvas_snapshot(4_096.0, 4_096, 0, 1).into_evidence(); + let solid = canvas_snapshot(4_096.0, 4_096, 4_096, 1).into_evidence(); + let drawn = canvas_snapshot(4_096.0, 4_096, 4_096, 2).into_evidence(); + let hidden_drawn = canvas_snapshot(0.0, 4_096, 4_096, 2).into_evidence(); + + assert_eq!(transparent.non_empty, Some(false)); + assert_eq!(solid.non_empty, Some(false)); + assert_eq!(drawn.non_empty, Some(true)); + assert_eq!(canvas_validation_diagnostic(&[]), Some("未发现可见 canvas")); + assert_eq!( + canvas_validation_diagnostic(&[hidden_drawn]), + Some("未发现可见 canvas") + ); + assert_eq!( + canvas_validation_diagnostic(&[transparent.clone()]), + Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") + ); + assert_eq!( + canvas_validation_diagnostic(&[solid.clone()]), + Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") + ); + assert_eq!( + canvas_validation_diagnostic(&[transparent, solid]), + Some("可见 canvas 未探测到至少两种有意义的像素颜色/alpha 状态") + ); + assert_eq!(canvas_validation_diagnostic(&[drawn]), None); +} + +#[test] +fn canvas_probe_serialization_keeps_the_v1_output_structure() { + let value = serde_json::to_value(canvas_snapshot(4_096.0, 4_096, 4_096, 2).into_evidence()) + .expect("serialize canvas evidence"); + let object = value.as_object().expect("canvas evidence object"); + + assert_eq!(object.len(), 9); + for field in [ + "width", + "height", + "cssWidth", + "cssHeight", + "visibleArea", + "sampleCount", + "nonEmptyPixelCount", + "nonEmpty", + "probeError", + ] { + assert!(object.contains_key(field), "missing output field {field}"); + } + assert!(!object.contains_key("distinctPixelStateCount")); +} + +#[test] +fn result_serializes_with_camel_case_evidence_paths() { + let result = BrowserValidationResult { + schema_version: RESULT_SCHEMA_VERSION.to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/1".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: true, + viewport_results: Vec::new(), + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: PathBuf::from("/tmp/evidence"), + report_path: PathBuf::from("/tmp/evidence/validation.json"), + }, + completed_at_unix_ms: 1, + }; + let value = serde_json::to_value(&result).expect("serialize result"); + assert_eq!(value["schemaVersion"], RESULT_SCHEMA_VERSION); + assert_eq!(value["completedAtUnixMs"], 1); + assert_eq!( + value["evidence"]["reportPath"], + "/tmp/evidence/validation.json" + ); + assert!(value.get("playtest").is_none()); + assert_eq!( + serde_json::from_value::(value) + .expect("deserialize static result") + .playtest, + None + ); +} + +#[test] +fn persisted_report_uses_only_relative_evidence_paths() { + let evidence_root = PathBuf::from("/tmp/browser-evidence"); + let result = BrowserValidationResult { + schema_version: RESULT_SCHEMA_VERSION.to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/1".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: true, + viewport_results: vec![BrowserViewportValidationResult { + viewport: BrowserValidationViewport::Desktop, + width: 1440, + height: 900, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: "fixture".to_string(), + visible_text_character_count: 7, + dom_character_count: 7, + 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: evidence_root.join("desktop.png"), + passed: true, + diagnostics: Vec::new(), + }], + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: evidence_root.clone(), + report_path: evidence_root.join("validation.json"), + }, + completed_at_unix_ms: 1, + }; + + let persisted = + browser_validation_result_for_report(&result).expect("build relative browser report"); + assert_eq!(persisted.evidence.root, PathBuf::from(".")); + assert_eq!( + persisted.evidence.report_path, + PathBuf::from("validation.json") + ); + assert_eq!( + persisted.viewport_results[0].screenshot_path, + PathBuf::from("desktop.png") + ); + assert_eq!(result.evidence.root, evidence_root); + assert!(result.viewport_results[0].screenshot_path.is_absolute()); +} + +#[test] +fn browser_discovery_does_not_trust_a_path_candidate() { + let _guard = PATH_TEST_LOCK.lock().expect("path test lock"); + let directory = tempfile::tempdir().expect("fake browser directory"); + let executable_name = if cfg!(target_os = "windows") { + "chrome.exe" + } else { + "google-chrome" + }; + let fake_browser = directory.path().join(executable_name); + fs::write(&fake_browser, b"not a trusted browser").expect("fake browser"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(&fake_browser) + .expect("fake browser metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_browser, permissions).expect("fake browser permissions"); + } + let fake_browser = fake_browser.canonicalize().expect("canonical fake browser"); + let original_path = env::var_os("PATH"); + env::set_var("PATH", directory.path()); + let discovered = discover_chrome_or_edge().ok(); + if let Some(original_path) = original_path { + env::set_var("PATH", original_path); + } else { + env::remove_var("PATH"); + } + + assert_ne!( + discovered.map(|browser| browser.executable_path), + Some(fake_browser) + ); +} + +#[test] +fn browser_discovery_only_builds_absolute_system_candidates() { + assert!(system_browser_candidates() + .iter() + .all(|(path, _kind)| path.is_absolute())); +} + +#[cfg(unix)] +#[test] +fn browser_process_temp_dir_keeps_chrome_singleton_socket_path_short() { + use std::os::unix::ffi::OsStrExt; + + let directory = create_browser_process_temp_dir().expect("browser process tempdir"); + let singleton_socket = directory + .path() + .join("com.google.Chrome.XXXXXX/SingletonSocket"); + + assert!(directory.path().starts_with("/tmp")); + assert!(singleton_socket.as_os_str().as_bytes().len() < 108); +} + +#[test] +fn fetch_interception_covers_all_resources_before_the_request_is_sent() { + let params = preview_fetch_enable_params(); + let patterns = params.patterns.expect("fetch interception patterns"); + + assert_eq!(patterns.len(), 1); + assert_eq!(patterns[0].url_pattern.as_deref(), Some("*")); + assert_eq!(patterns[0].resource_type, None); + assert_eq!(patterns[0].request_stage, Some(RequestStage::Request)); +} + +#[test] +fn request_policy_blocks_cross_origin_http_redirects_and_websockets() { + let origin = Url::parse("http://127.0.0.1:34567/").expect("preview origin"); + + assert_eq!( + preview_request_decision( + "http://127.0.0.1:34567/game.js", + &ResourceType::Script, + false, + &origin, + ), + PreviewRequestDecision::Allow + ); + assert_eq!( + preview_request_decision( + "ws://127.0.0.1:34567/socket", + &ResourceType::WebSocket, + false, + &origin, + ), + PreviewRequestDecision::Allow + ); + for url in [ + "ws://127.0.0.1:34568/socket", + "ws://example.com/socket", + "wss://127.0.0.1:34567/socket", + "http://127.0.0.1:34567/not-a-websocket", + ] { + assert_eq!( + preview_request_decision(url, &ResourceType::WebSocket, false, &origin), + PreviewRequestDecision::Block(PreviewRequestBlockReason::WebSocketBeforeHandshake), + "accepted WebSocket request {url}" + ); + } + assert_eq!( + preview_request_decision( + "http://127.0.0.1:34568/private", + &ResourceType::Fetch, + false, + &origin, + ), + PreviewRequestDecision::Block(PreviewRequestBlockReason::CrossOrigin) + ); + assert_eq!( + preview_request_decision( + "https://example.com/redirect-target", + &ResourceType::Document, + true, + &origin, + ), + PreviewRequestDecision::Block(PreviewRequestBlockReason::RedirectTarget) + ); +} + +#[test] +fn proxy_bypass_is_limited_to_the_preview_http_and_websocket_origin() { + let origin = Url::parse("http://127.0.0.1:34567/").expect("preview origin"); + + assert_eq!( + preview_proxy_bypass_list(&origin), + "<-loopback>;http://127.0.0.1:34567;ws://127.0.0.1:34567" + ); +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_blocks_http_redirect_and_websocket_before_connection() { + use std::io::{ErrorKind, Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let blocked_listener = + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind blocked origin"); + let blocked_port = blocked_listener + .local_addr() + .expect("blocked origin address") + .port(); + blocked_listener + .set_nonblocking(true) + .expect("nonblocking blocked origin"); + + let preview_listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let preview_port = preview_listener + .local_addr() + .expect("preview address") + .port(); + preview_listener + .set_nonblocking(true) + .expect("nonblocking preview"); + let html = format!( + r#"
Network policy probe
"# + ) + .into_bytes(); + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match preview_listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let count = stream.read(&mut request).unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..count]); + if request.starts_with("GET /redirect ") { + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{blocked_port}/redirected\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(response.as_bytes()); + } else { + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(&html); + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{preview_port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Network policy probe".to_string()], + settle_ms: 200, + fail_on_console_error: false, + playtest_scenario: None, + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + let result = validation.expect("real browser network validation"); + assert_eq!(result.viewport_results.len(), REQUIRED_VIEWPORTS.len()); + assert!(result.viewport_results.iter().all(|viewport| { + !viewport.passed + && viewport + .diagnostics + .iter() + .any(|diagnostic| diagnostic == "未发现可见 canvas") + })); + let failed_requests = &result.viewport_results[0].failed_requests; + + assert!(!result.passed); + assert!( + failed_requests.iter().any(|request| { + request.blocked_by_policy + && request.url == format!("http://127.0.0.1:{blocked_port}/direct") + && request.error_text == PreviewRequestBlockReason::CrossOrigin.message() + }), + "{failed_requests:#?}" + ); + assert!( + failed_requests.iter().any(|request| { + request.blocked_by_policy + && request.url == format!("http://127.0.0.1:{blocked_port}/redirected") + }), + "{failed_requests:#?}" + ); + assert!( + failed_requests.iter().any(|request| { + request.blocked_by_policy + && request.resource_type == "WebSocket" + && request.error_text + == PreviewRequestBlockReason::WebSocketBeforeHandshake.message() + }), + "{failed_requests:#?}" + ); + thread::sleep(Duration::from_millis(100)); + match blocked_listener.accept() { + Err(error) if error.kind() == ErrorKind::WouldBlock => {} + Ok(_) => panic!("blocked origin received a TCP connection"), + Err(error) => panic!("blocked origin accept failed: {error}"), + } +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_lane_defense_playtest() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let html = br#" + +Lane Defense Browser Fixture + +
Lane defense fixture
+ +
+ + + + + + +
+ + + +"#; + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let validation = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Lane defense fixture".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: Some(BrowserPlaytestScenario::LaneDefenseV1), + evidence_root: evidence.path().join("evidence"), + }) + .await; + let _ = stop_tx.send(()); + server.join().expect("preview server"); + + let result = validation.expect("real lane-defense browser validation"); + assert!(result.passed, "{:#?}", result.diagnostics); + assert!(result.evidence.report_path.is_file()); + assert!(result.viewport_results.iter().all(|viewport| { + viewport.passed + && viewport + .canvases + .iter() + .any(|canvas| canvas.non_empty == Some(true)) + })); + let playtest = result.playtest.expect("lane-defense playtest result"); + assert!(playtest.passed, "{:#?}", playtest.diagnostics); + assert_eq!(playtest.scenario, BrowserPlaytestScenario::LaneDefenseV1); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.initial_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.initial_level, Some(1)); + assert_eq!(playtest.final_sequence, Some(9)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.final_level, Some(2)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_validation_smoke() { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + discover_chrome_or_edge().expect("Chrome, Chromium, or Edge must be installed"); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); + let port = listener.local_addr().expect("preview address").port(); + listener.set_nonblocking(true).expect("nonblocking preview"); + let (stop_tx, stop_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let html = br#"Browser Probe
Expected local preview
"#; + while stop_rx.try_recv().is_err() { + match listener.accept() { + Ok((mut stream, _)) => { + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(html); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("preview accept failed: {error}"), + } + } + }); + let evidence = tempfile::tempdir().expect("evidence tempdir"); + let result = validate_local_preview_in_browser(BrowserValidationInput { + url: format!("http://127.0.0.1:{port}/"), + viewports: REQUIRED_VIEWPORTS.to_vec(), + expected_text: vec!["Expected local preview".to_string()], + settle_ms: 100, + fail_on_console_error: true, + playtest_scenario: None, + evidence_root: evidence.path().join("evidence"), + }) + .await + .expect("real browser validation"); + let _ = stop_tx.send(()); + server.join().expect("preview server"); + assert!(result.passed, "{:?}", result.diagnostics); + assert_eq!(result.viewport_results.len(), REQUIRED_VIEWPORTS.len()); + assert!(result.evidence.report_path.is_file()); + for (viewport_result, expected_viewport) in + result.viewport_results.iter().zip(REQUIRED_VIEWPORTS) + { + let (width, height, _) = expected_viewport.dimensions(); + assert_eq!(viewport_result.viewport, expected_viewport); + assert_eq!( + (viewport_result.width, viewport_result.height), + (width, height) + ); + assert!(viewport_result.screenshot_path.is_file()); + assert!(viewport_result.expected_text[0].found); + assert!(viewport_result + .console_warnings + .iter() + .any(|warning| warning.text.contains("probe warning"))); + assert_eq!(viewport_result.canvases.len(), 3); + assert_eq!(viewport_result.canvases[0].non_empty_pixel_count, 0); + assert_eq!(viewport_result.canvases[0].non_empty, Some(false)); + assert_eq!( + viewport_result.canvases[1].non_empty_pixel_count, + viewport_result.canvases[1].sample_count + ); + assert_eq!(viewport_result.canvases[1].non_empty, Some(false)); + assert_eq!(viewport_result.canvases[2].non_empty, Some(true)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs index 4b43fa9c7..b39f0484e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli.rs @@ -4,252 +4,26 @@ use std::io::{BufRead, Write}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::time::{Duration, Instant}; -const SWARM_CHAT_POLL_INTERVAL: Duration = Duration::from_millis(250); -const SWARM_CHAT_SETTLE_WINDOW: Duration = Duration::from_millis(1_500); -const SWARM_CHAT_HISTORY_LIMIT: usize = 50; -const SWARM_CHAT_PLAN_STEP_LIMIT: usize = 8; -const SWARM_TURN_REPORT_PREFIX: &str = "[turn.report] "; -const SWARM_TURN_REPORT_SCHEMA_VERSION: &str = "game-creator-swarm-turn-report.v1"; -const SWARM_TURN_FAILED_ERROR: &str = "swarm-turn-failed"; -const SWARM_TURN_INCOMPLETE_ERROR: &str = "swarm-turn-incomplete"; -const SWARM_TURN_RECONCILIATION_ERROR: &str = "swarm-turn-needs-reconciliation"; +mod commands; +mod conversation; +mod goal_commands; +mod input; +mod observer; +mod report; +mod terminal_classification; +mod turn_wait; -#[derive(Debug, Eq, PartialEq)] -enum SwarmChatInput { - Help, - Agents, - Status, - History, - Compact, - Mcp, - Goal(SwarmGoalCommand), - InvalidGoal(String), - Quit, - Message(String), -} +use commands::*; +use conversation::*; +use goal_commands::*; +use input::*; +use observer::*; +use report::*; +use terminal_classification::*; +use turn_wait::*; -#[derive(Debug, Eq, PartialEq)] -enum SwarmGoalCommand { - Status, - Start(String), - Edit(String), - Pause, - Resume, - Clear, -} - -#[derive(Debug, Eq, PartialEq)] -struct SwarmGoalObservation { - session_id: String, - run_id: String, - previous_message_count: usize, -} - -#[derive(Default)] -struct SwarmRuntimeObserver { - state_signatures: BTreeMap, - seen_events: BTreeSet, - handled_confirmations: BTreeSet, - handled_user_input_requests: BTreeSet, - user_input_response_ids: BTreeMap, - response_streams: BTreeMap, - open_response_line: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SwarmResponseStreamIdentity { - task_id: String, - session_id: String, - run_id: String, - request_slot: String, - applied_steer_cursor: u64, - response_revision: u64, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SwarmResponseStreamCursor { - identity: SwarmResponseStreamIdentity, - session_id: String, - sequence: u64, - status: String, - accumulated_text: String, - printed_accumulated_text: Option, - connected: bool, - rejected_snapshot: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SwarmResponseStreamLine { - agent_id: String, - identity: SwarmResponseStreamIdentity, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SwarmRejectedResponseStreamSnapshot { - session_id: String, - sequence: u64, - status: String, - accumulated_text: String, -} - -#[derive(Debug, Eq, PartialEq)] -enum SwarmTurnOutcome { - Settled(SwarmTurnReport), - Failed { - agent_ids: Vec, - report: SwarmTurnReport, - }, - Incomplete { - reasons: Vec, - report: SwarmTurnReport, - }, - NeedsReconciliation { - agent_ids: Vec, - report: SwarmTurnReport, - }, - Quit, -} - -#[derive(Debug, Eq, PartialEq)] -struct SwarmTurnObservation { - outcome: SwarmTurnOutcome, - input_closed: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SwarmChatFlow { - Continue, - Exit, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] -#[serde(rename_all = "kebab-case")] -enum SwarmTurnReportOutcome { - Settled, - Failed, - Incomplete, - NeedsReconciliation, -} - -#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct SwarmTurnReport { - schema_version: &'static str, - outcome: SwarmTurnReportOutcome, - parent_agent_id: String, - session_id: String, - parent_run_id: Option, - runtime_count: usize, - busy_runtime_count: usize, - pending_task_count: u64, - running_task_count: u64, - waiting_for_confirmation_count: u64, - waiting_for_user_input_count: u64, - new_assistant_message_count: usize, - final_reply_chars: usize, - reconciliation_agent_count: usize, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct SwarmTurnConversationMetrics { - new_assistant_message_count: usize, - final_reply_chars: usize, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SwarmRecoveredAssistant { - run_id: String, - finalization_id: String, - message_id: String, - content: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SwarmTurnConversationBaseline { - previous_message_count: usize, - parent_run_id: String, - recovered_assistant: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct SwarmTurnConversationSnapshot { - metrics: SwarmTurnConversationMetrics, - final_reply: Option, - recovered_before_observation: bool, -} - -enum SwarmInputEvent { - Line(String), - Eof, - Error(String), -} - -enum SwarmConfirmationResolution { - None, - Handled, - InputClosed, - Quit, -} - -enum SwarmPromptDecision { - Approve, - Reject, - Deferred, - InputClosed, - Quit, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SwarmTurnTerminalClassification { - Settled, - Failed, - Incomplete, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SwarmSpecialistFailureDisposition { - Recoverable, - Failed, - Incomplete, -} - -#[derive(Default)] -struct SwarmTerminalFailureScan { - failed_agents: Vec, - incomplete_reasons: Vec, - reconciliation_agents: Vec, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SwarmNewRunLaunch<'a> { - ProjectSupervisor { - source: &'static str, - run_profile: &'a str, - }, - ExplicitParentDebug, -} - -fn resolve_swarm_new_run_launch<'a>( - parent_agent_id: &str, - run_profile: &'a str, -) -> Result, String> { - if !matches!( - run_profile, - AGENT_RUNTIME_RUN_PROFILE_STANDARD | AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - ) { - return Err(format!("不支持的 Agent Runtime Run Profile:{run_profile}")); - } - if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - return Ok(SwarmNewRunLaunch::ProjectSupervisor { - source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - run_profile, - }); - } - if run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { - return Err("--autonomous-game-build 仅支持 project-supervisor 总控入口".to_string()); - } - Ok(SwarmNewRunLaunch::ExplicitParentDebug) -} +#[cfg(test)] +mod tests; pub(crate) fn run_game_creator_swarm_chat_at( root: &Path, @@ -292,4129 +66,3 @@ pub(crate) fn run_game_creator_swarm_chat_at( &mut output, ) } - -fn run_game_creator_swarm_chat_with_input( - root: &Path, - parent_agent_id: &str, - run_profile: &str, - input: &Receiver, - output: &mut W, -) -> Result<(), String> { - let parent_agent_id = parent_agent_id.trim(); - if parent_agent_id.is_empty() { - return Err("parentAgentId 不能为空".to_string()); - } - let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?; - let project_path = root.display().to_string(); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - enforce_project_permission_policy(root, "agent.compact")?; - enforce_project_permission_policy(root, "agent.resume")?; - let _ = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; - let resumed = resume_game_creator_agent_background_tasks_at(root)?; - let existing_runtimes = read_game_creator_agent_runtimes_at(root)?; - - writeln!(output, "Agent Swarm Chat") - .and_then(|_| writeln!(output, "项目:{}", root.display())) - .and_then(|_| writeln!(output, "父 Agent:{parent_agent_id}")) - .and_then(|_| writeln!(output, "输入 /help 查看命令。")) - .map_err(|error| format!("写入终端失败:{error}"))?; - print_conversation_history(root, parent_agent_id, output)?; - if !resumed.is_empty() { - writeln!(output, "[恢复扫描] 已检查 {} 个 Runtime。", resumed.len()) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - - if runtimes_are_busy(&existing_runtimes) { - writeln!(output, "[恢复] 检测到未收束 Runtime,继续观察现有任务。") - .map_err(|error| format!("写入终端失败:{error}"))?; - let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; - let session_id = before - .session_id - .as_deref() - .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; - let parent_run_id = swarm_parent_runtime(parent_agent_id, session_id, &existing_runtimes) - .map(|runtime| runtime.state.run_id.as_str()) - .unwrap_or_default(); - let mut conversation_baseline = - new_swarm_turn_conversation_baseline(before.messages.len(), parent_run_id); - capture_recovered_swarm_assistant_at( - root, - parent_agent_id, - session_id, - &mut conversation_baseline, - )?; - let mut observer = SwarmRuntimeObserver::default(); - let outcome = wait_for_swarm_turn( - root, - parent_agent_id, - session_id, - conversation_baseline, - input, - output, - &mut observer, - SWARM_CHAT_POLL_INTERVAL, - SWARM_CHAT_SETTLE_WINDOW, - )?; - if outcome == SwarmTurnOutcome::Quit { - return print_swarm_chat_exit(output); - } - print_turn_outcome(outcome, output)?; - } - - loop { - write!(output, "\n你> ").map_err(|error| format!("写入终端失败:{error}"))?; - output - .flush() - .map_err(|error| format!("刷新终端失败:{error}"))?; - let Some(line) = receive_swarm_chat_line(input)? else { - writeln!(output, "\n已退出 Agent Swarm Chat。") - .map_err(|error| format!("写入终端失败:{error}"))?; - return Ok(()); - }; - let Some(command) = parse_swarm_chat_input(&line) else { - continue; - }; - match command { - SwarmChatInput::Help => print_swarm_chat_help(output)?, - SwarmChatInput::Agents => print_swarm_agents(root, output)?, - SwarmChatInput::Status => print_swarm_status(root, output)?, - SwarmChatInput::History => print_conversation_history(root, parent_agent_id, output)?, - SwarmChatInput::Compact => { - handle_swarm_context_compaction(root, parent_agent_id, output)? - } - SwarmChatInput::Mcp => print_swarm_mcp_status(root, output)?, - SwarmChatInput::Goal(command) => { - let mut observer = SwarmRuntimeObserver::seed(root)?; - let Some(observation) = - handle_swarm_goal_command(root, parent_agent_id, command, output)? - else { - continue; - }; - let conversation_baseline = new_swarm_turn_conversation_baseline( - observation.previous_message_count, - &observation.run_id, - ); - let outcome = wait_for_swarm_turn( - root, - parent_agent_id, - &observation.session_id, - conversation_baseline, - input, - output, - &mut observer, - SWARM_CHAT_POLL_INTERVAL, - SWARM_CHAT_SETTLE_WINDOW, - )?; - if outcome == SwarmTurnOutcome::Quit { - return print_swarm_chat_exit(output); - } - print_turn_outcome(outcome, output)?; - } - SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, - SwarmChatInput::Quit => { - writeln!( - output, - "已退出 Agent Swarm Chat;后台 Runner 和已投递任务保持运行。" - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - return Ok(()); - } - SwarmChatInput::Message(message) => { - let before = - read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; - let session_id = before - .session_id - .clone() - .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; - let mut observer = SwarmRuntimeObserver::seed(root)?; - if let Some(goal) = - read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)? - { - match goal.status.as_str() { - AGENT_GOAL_STATUS_ACTIVE => { - let steer_id = format!("swarm-goal-steer-{}", unix_millis()); - let result = steer_game_creator_agent_runtime_task( - project_path.clone(), - parent_agent_id.to_string(), - session_id.clone(), - goal.run_id.clone(), - steer_id.clone(), - message, - )?; - writeln!( - output, - "[Goal 已追加] run={} steer={} providerInterrupted={}", - goal.run_id, steer_id, result.provider_interrupted - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - let conversation_baseline = new_swarm_turn_conversation_baseline( - before.messages.len(), - &goal.run_id, - ); - let outcome = wait_for_swarm_turn( - root, - parent_agent_id, - &session_id, - conversation_baseline, - input, - output, - &mut observer, - SWARM_CHAT_POLL_INTERVAL, - SWARM_CHAT_SETTLE_WINDOW, - )?; - if outcome == SwarmTurnOutcome::Quit { - return print_swarm_chat_exit(output); - } - print_turn_outcome(outcome, output)?; - continue; - } - AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => { - print_swarm_goal_error( - output, - "当前 Goal 已暂停;请先输入 /goal resume。", - )?; - continue; - } - AGENT_GOAL_STATUS_CLEARING => { - print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?; - continue; - } - AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => { - print_swarm_goal_error( - output, - "当前 Goal 需要人工 reconciliation,暂不接受新消息。", - )?; - continue; - } - AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {} - status => { - print_swarm_goal_error( - output, - &format!("当前 Goal 状态未知,已阻止发送:{status}"), - )?; - continue; - } - } - } - let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis()); - let started = match new_run_launch { - SwarmNewRunLaunch::ProjectSupervisor { - source, - run_profile, - } => start_game_creator_supervisor_background_task_for_session_at( - root, - Some(&session_id), - &message, - &requested_run_id, - source, - run_profile, - )?, - SwarmNewRunLaunch::ExplicitParentDebug => { - start_game_creator_agent_runtime_task( - project_path.clone(), - parent_agent_id.to_string(), - Some(session_id.clone()), - message, - requested_run_id.clone(), - )? - } - }; - writeln!( - output, - "[已投递] agent={} session={} run={}", - parent_agent_id, started.state.session_id, requested_run_id - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - let conversation_baseline = new_swarm_turn_conversation_baseline( - before.messages.len(), - &started.state.run_id, - ); - let outcome = wait_for_swarm_turn( - root, - parent_agent_id, - &session_id, - conversation_baseline, - input, - output, - &mut observer, - SWARM_CHAT_POLL_INTERVAL, - SWARM_CHAT_SETTLE_WINDOW, - )?; - if outcome == SwarmTurnOutcome::Quit { - return print_swarm_chat_exit(output); - } - print_turn_outcome(outcome, output)?; - } - } - } -} - -fn receive_swarm_chat_line(input: &Receiver) -> Result, String> { - match input.recv() { - Ok(SwarmInputEvent::Line(line)) => Ok(Some(line)), - Ok(SwarmInputEvent::Eof) | Err(_) => Ok(None), - Ok(SwarmInputEvent::Error(error)) => Err(format!("读取终端输入失败:{error}")), - } -} - -fn prompt_swarm_decision( - root: &Path, - parent_agent_id: &str, - input: &Receiver, - output: &mut W, - prompt: &str, -) -> Result { - write!(output, "{prompt}").map_err(|error| format!("写入终端失败:{error}"))?; - output - .flush() - .map_err(|error| format!("刷新终端失败:{error}"))?; - loop { - let Some(line) = receive_swarm_chat_line(input)? else { - return Ok(SwarmPromptDecision::InputClosed); - }; - match line.to_ascii_lowercase().as_str() { - "approve" | "yes" | "y" | "批准" => return Ok(SwarmPromptDecision::Approve), - "reject" | "no" | "n" | "拒绝" => return Ok(SwarmPromptDecision::Reject), - "/quit" | "/exit" => return Ok(SwarmPromptDecision::Quit), - _ => { - if let Some(command) = parse_swarm_chat_input(&line) { - match command { - SwarmChatInput::Goal(command) => { - let _ = - handle_swarm_goal_command(root, parent_agent_id, command, output)?; - return Ok(SwarmPromptDecision::Deferred); - } - SwarmChatInput::InvalidGoal(error) => { - print_swarm_goal_error(output, &error)?; - } - SwarmChatInput::Compact => { - handle_swarm_context_compaction(root, parent_agent_id, output)?; - return Ok(SwarmPromptDecision::Deferred); - } - SwarmChatInput::Mcp => { - print_swarm_mcp_status(root, output)?; - } - _ => {} - } - } - write!(output, "请输入 approve 或 reject:") - .map_err(|error| format!("写入终端失败:{error}"))?; - output - .flush() - .map_err(|error| format!("刷新终端失败:{error}"))?; - } - } - } -} - -fn print_swarm_chat_exit(output: &mut W) -> Result<(), String> { - writeln!( - output, - "已退出 Agent Swarm Chat;后台 Runner 和已投递任务保持运行。" - ) - .map_err(|error| format!("写入终端失败:{error}")) -} - -fn parse_swarm_chat_input(input: &str) -> Option { - let input = input.trim(); - if input.is_empty() { - return None; - } - if let Some(rest) = input.strip_prefix("/goal") { - if rest.is_empty() { - return Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)); - } - if !rest.chars().next().is_some_and(char::is_whitespace) { - return Some(SwarmChatInput::InvalidGoal( - "未知 /goal 命令;输入 /help 查看支持的 Goal 命令。".to_string(), - )); - } - return Some(parse_swarm_goal_command(rest.trim())); - } - Some(match input { - "/help" => SwarmChatInput::Help, - "/agents" => SwarmChatInput::Agents, - "/status" => SwarmChatInput::Status, - "/history" => SwarmChatInput::History, - "/compact" => SwarmChatInput::Compact, - "/mcp" => SwarmChatInput::Mcp, - "/quit" | "/exit" => SwarmChatInput::Quit, - value => SwarmChatInput::Message(value.to_string()), - }) -} - -fn parse_swarm_goal_command(input: &str) -> SwarmChatInput { - if input.is_empty() || input == "status" { - return SwarmChatInput::Goal(SwarmGoalCommand::Status); - } - if input == "pause" { - return SwarmChatInput::Goal(SwarmGoalCommand::Pause); - } - if input == "resume" { - return SwarmChatInput::Goal(SwarmGoalCommand::Resume); - } - if input == "clear" { - return SwarmChatInput::Goal(SwarmGoalCommand::Clear); - } - if let Some(outcome) = input.strip_prefix("edit") { - if outcome.is_empty() { - return SwarmChatInput::InvalidGoal("用法:/goal edit <目标>".to_string()); - } - if outcome.chars().next().is_some_and(char::is_whitespace) { - let outcome = outcome.trim(); - return if outcome.is_empty() { - SwarmChatInput::InvalidGoal("用法:/goal edit <目标>".to_string()) - } else { - SwarmChatInput::Goal(SwarmGoalCommand::Edit(outcome.to_string())) - }; - } - } - for command in ["status", "pause", "resume", "clear"] { - if input - .strip_prefix(command) - .is_some_and(|rest| rest.chars().next().is_some_and(char::is_whitespace)) - { - return SwarmChatInput::InvalidGoal(format!("/goal {command} 不接受额外参数")); - } - } - SwarmChatInput::Goal(SwarmGoalCommand::Start(input.to_string())) -} - -fn print_swarm_chat_help(output: &mut W) -> Result<(), String> { - writeln!(output, "/agents 查看静态 Agent 与动态 child") - .and_then(|_| writeln!(output, "/status 查看全部 Runtime 状态")) - .and_then(|_| writeln!(output, "/history 查看父 Agent 当前 Session 历史")) - .and_then(|_| writeln!(output, "/compact 压缩父 Agent 当前空闲 Session 历史")) - .and_then(|_| writeln!(output, "/mcp 查看 Runner MCP server 与工具目录")) - .and_then(|_| writeln!(output, "/goal <目标> 启动当前 Session 的持久 Goal")) - .and_then(|_| writeln!(output, "/goal 查看当前 Goal")) - .and_then(|_| writeln!(output, "/goal status 查看当前 Goal")) - .and_then(|_| writeln!(output, "/goal edit <目标> 编辑当前 Goal")) - .and_then(|_| writeln!(output, "/goal pause 暂停当前 Goal")) - .and_then(|_| writeln!(output, "/goal resume 恢复当前 Goal")) - .and_then(|_| writeln!(output, "/goal clear 清理当前 Goal")) - .and_then(|_| writeln!(output, "/help 查看命令")) - .and_then(|_| writeln!(output, "/quit 退出终端观察客户端")) - .map_err(|error| format!("写入终端失败:{error}")) -} - -fn print_swarm_mcp_status(root: &Path, output: &mut W) -> Result<(), String> { - match read_external_agent_runner_mcp_catalog(root) { - Ok(catalog) => print_swarm_mcp_catalog(&catalog, output), - Err(error) => writeln!(output, "[MCP] 状态读取失败:{error}") - .map_err(|write_error| format!("写入终端失败:{write_error}")), - } -} - -fn print_swarm_mcp_catalog( - catalog: &GameCreatorMcpCatalog, - output: &mut W, -) -> Result<(), String> { - writeln!( - output, - "[MCP] catalog={} servers={} tools={}", - catalog.fingerprint.chars().take(12).collect::(), - catalog.servers.len(), - catalog.tools.len(), - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - for server in &catalog.servers { - writeln!( - output, - " server={} transport={} enabled={} connected={} required={} tools={}{}", - server.server_id, - server.transport, - server.enabled, - server.connected, - server.required, - server.tool_count, - server - .error - .as_deref() - .map(|error| format!(" error={error}")) - .unwrap_or_default(), - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - for tool in &catalog.tools { - let description = sanitize_prompt_context(&tool.description) - .chars() - .take(180) - .collect::() - .split_whitespace() - .collect::>() - .join(" "); - writeln!( - output, - " tool={}/{} approval={} readOnly={} schema={}{}", - tool.server_id, - tool.name, - tool.effective_approval_mode, - tool.read_only_hint, - serde_json::to_string(&tool.input_schema) - .unwrap_or_else(|_| "{}".to_string()) - .chars() - .take(600) - .collect::(), - if description.is_empty() { - String::new() - } else { - format!(" description={description}") - }, - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - Ok(()) -} - -fn handle_swarm_context_compaction( - root: &Path, - parent_agent_id: &str, - output: &mut W, -) -> Result<(), String> { - let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; - match compact_external_agent_runner_context( - root, - parent_agent_id, - conversation.session_id.as_deref(), - ) { - Ok(result) => writeln!( - output, - "[上下文压缩] revision={} reused={} estimated={}->{} covered={}/{}/{}", - result.revision, - result.reused, - result.estimated_tokens_before, - result.estimated_tokens_after, - result.covered_agent_messages, - result.covered_project_messages, - result.covered_observations, - ), - Err(error) => writeln!(output, "[上下文压缩失败] {error}"), - } - .map_err(|error| format!("写入终端失败:{error}")) -} - -fn handle_swarm_goal_command( - root: &Path, - parent_agent_id: &str, - command: SwarmGoalCommand, - output: &mut W, -) -> Result, String> { - match execute_swarm_goal_command(root, parent_agent_id, command, output) { - Ok(observation) => Ok(observation), - Err(error) => { - print_swarm_goal_error(output, &error)?; - Ok(None) - } - } -} - -fn execute_swarm_goal_command( - root: &Path, - parent_agent_id: &str, - command: SwarmGoalCommand, - output: &mut W, -) -> Result, String> { - let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; - let session_id = conversation - .session_id - .clone() - .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; - let previous_message_count = conversation.messages.len(); - let current_goal = read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)?; - let project_path = root.display().to_string(); - - match command { - SwarmGoalCommand::Status => { - print_swarm_goal_status(&session_id, current_goal.as_ref(), output)?; - Ok(None) - } - SwarmGoalCommand::Start(outcome) => { - let requested_run_id = format!("swarm-goal-{parent_agent_id}-{}", unix_millis()); - let result = start_game_creator_agent_goal( - project_path, - parent_agent_id.to_string(), - Some(session_id.clone()), - outcome.clone(), - Vec::new(), - vec![outcome], - requested_run_id, - )?; - print_swarm_goal_mutation("已启动", &result, output)?; - Ok(Some(SwarmGoalObservation { - session_id, - run_id: result.goal.run_id.clone(), - previous_message_count, - })) - } - SwarmGoalCommand::Edit(outcome) => { - let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; - let result = edit_game_creator_agent_goal( - project_path, - parent_agent_id.to_string(), - session_id.clone(), - goal.goal_id, - goal.revision, - outcome.clone(), - Vec::new(), - vec![outcome], - )?; - print_swarm_goal_mutation("已编辑", &result, output)?; - Ok( - (result.goal.status == AGENT_GOAL_STATUS_ACTIVE).then_some(SwarmGoalObservation { - session_id, - run_id: result.goal.run_id.clone(), - previous_message_count, - }), - ) - } - SwarmGoalCommand::Pause => { - let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; - let result = pause_game_creator_agent_goal( - project_path, - parent_agent_id.to_string(), - session_id, - goal.goal_id, - goal.revision, - )?; - print_swarm_goal_mutation("已暂停", &result, output)?; - Ok(None) - } - SwarmGoalCommand::Resume => { - let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; - let result = resume_game_creator_agent_goal( - project_path, - parent_agent_id.to_string(), - session_id.clone(), - goal.goal_id, - goal.revision, - )?; - print_swarm_goal_mutation("已恢复", &result, output)?; - Ok(Some(SwarmGoalObservation { - session_id, - run_id: result.goal.run_id.clone(), - previous_message_count, - })) - } - SwarmGoalCommand::Clear => { - let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; - let result = clear_game_creator_agent_goal( - project_path, - parent_agent_id.to_string(), - session_id, - goal.goal_id, - goal.revision, - )?; - print_swarm_goal_mutation("已清理", &result, output)?; - Ok(None) - } - } -} - -fn print_swarm_goal_status( - session_id: &str, - goal: Option<&AgentGoalRecord>, - output: &mut W, -) -> Result<(), String> { - let Some(goal) = goal else { - return writeln!(output, "[Goal] session={session_id} 当前尚未设置持久目标。") - .map_err(|error| format!("写入终端失败:{error}")); - }; - writeln!( - output, - "[Goal] session={} goal={} run={} revision={} status={}", - goal.session_id, goal.goal_id, goal.run_id, goal.revision, goal.status - ) - .and_then(|_| writeln!(output, "[Goal 目标] {}", goal.outcome)) - .map_err(|error| format!("写入终端失败:{error}"))?; - for constraint in &goal.constraints { - writeln!(output, "[Goal 约束] {constraint}") - .map_err(|error| format!("写入终端失败:{error}"))?; - } - for verification in &goal.verification { - writeln!(output, "[Goal 完成标准] {verification}") - .map_err(|error| format!("写入终端失败:{error}"))?; - } - if let Some(error) = goal.error.as_deref() { - writeln!(output, "[Goal 错误] {error}") - .map_err(|write_error| format!("写入终端失败:{write_error}"))?; - } - Ok(()) -} - -fn print_swarm_goal_mutation( - action: &str, - result: &AgentGoalMutationResult, - output: &mut W, -) -> Result<(), String> { - writeln!( - output, - "[Goal {action}] goal={} run={} revision={} status={} providerInterrupted={}", - result.goal.goal_id, - result.goal.run_id, - result.goal.revision, - result.goal.status, - result.provider_interrupted - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - print_swarm_goal_status(&result.goal.session_id, Some(&result.goal), output) -} - -fn print_swarm_goal_error(output: &mut W, error: &str) -> Result<(), String> { - writeln!(output, "[Goal 失败] {error}") - .map_err(|write_error| format!("写入终端失败:{write_error}")) -} - -fn print_swarm_agents(root: &Path, output: &mut W) -> Result<(), String> { - writeln!(output, "静态 Agent:").map_err(|error| format!("写入终端失败:{error}"))?; - for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - for role in group.roles { - writeln!( - output, - "- {} / {}: {} ({})", - group.label, role.role, role.task_id, role.id - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - } - let dynamic = read_game_creator_agent_runtimes_at(root)? - .into_iter() - .filter(|runtime| runtime.state.agent_id.starts_with("child-")) - .collect::>(); - if !dynamic.is_empty() { - writeln!(output, "动态隔离 Agent:").map_err(|error| format!("写入终端失败:{error}"))?; - for runtime in dynamic { - writeln!( - output, - "- {} <- {} run={} delegation={} status={}/{}", - runtime.state.agent_id, - runtime - .state - .parent_agent_id - .as_deref() - .unwrap_or("unknown"), - runtime.state.run_id, - runtime.state.delegation_id.as_deref().unwrap_or("unknown"), - runtime.state.status, - runtime.state.phase - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - } - Ok(()) -} - -fn print_swarm_status(root: &Path, output: &mut W) -> Result<(), String> { - let runtimes = read_game_creator_agent_runtimes_at(root)?; - for runtime in runtimes.iter().filter(|runtime| { - !runtime.state.run_id.is_empty() - || runtime.task_queue.pending > 0 - || runtime.task_queue.running > 0 - || runtime.task_queue.waiting_for_confirmation > 0 - || runtime.task_queue.waiting_for_user_input > 0 - }) { - print_runtime_state(&runtime.state, &runtime.task_queue, output)?; - print_runtime_response_stream_status(runtime.response_stream.as_ref(), output)?; - } - if runtimes - .iter() - .all(|runtime| runtime.state.run_id.is_empty()) - { - writeln!(output, "当前没有 Agent Runtime 记录。") - .map_err(|error| format!("写入终端失败:{error}"))?; - } - Ok(()) -} - -fn print_runtime_response_stream_status( - stream: Option<&AgentRuntimeResponseStream>, - output: &mut W, -) -> Result<(), String> { - let Some(stream) = stream else { - return Ok(()); - }; - writeln!( - output, - "[回复流] status={} sequence={} chars={}", - stream.status, - stream.sequence, - stream.accumulated_text.chars().count() - ) - .map_err(|error| format!("写入终端失败:{error}")) -} - -fn print_conversation_history( - root: &Path, - parent_agent_id: &str, - output: &mut W, -) -> Result<(), String> { - let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; - writeln!( - output, - "[历史] session={} messages={}", - conversation.session_id.as_deref().unwrap_or("unknown"), - conversation.messages.len() - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - let start = conversation - .messages - .len() - .saturating_sub(SWARM_CHAT_HISTORY_LIMIT); - for message in &conversation.messages[start..] { - let label = if message.role == "assistant" { - "Agent" - } else if message.role == "user" { - "你" - } else { - message.role.as_str() - }; - writeln!(output, "{label}> {}", message.content) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - Ok(()) -} - -fn wait_for_swarm_turn( - root: &Path, - parent_agent_id: &str, - session_id: &str, - conversation_baseline: SwarmTurnConversationBaseline, - input: &Receiver, - output: &mut W, - observer: &mut SwarmRuntimeObserver, - poll_interval: Duration, - settle_window: Duration, -) -> Result { - let mut stable_since: Option = None; - let mut recovery_scan_required = true; - let mut last_runner_check = Instant::now(); - let mut input_closed = false; - loop { - let runtimes = read_game_creator_agent_runtimes_at(root)?; - let changed = observer.print_changes(&runtimes, output)?; - if changed { - stable_since = None; - } - let mut reconciliation = swarm_reconciliation_agents(&runtimes); - if !reconciliation.is_empty() { - observer.close_response_line(output)?; - return build_reconciliation_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - reconciliation, - ); - } - if !input_closed { - match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { - SwarmConfirmationResolution::Handled => { - stable_since = None; - recovery_scan_required = true; - continue; - } - SwarmConfirmationResolution::InputClosed => { - mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; - stable_since = None; - continue; - } - SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), - SwarmConfirmationResolution::None => {} - } - match observer.resolve_user_input_requests( - root, - parent_agent_id, - &runtimes, - input, - output, - )? { - SwarmConfirmationResolution::Handled => { - stable_since = None; - recovery_scan_required = true; - continue; - } - SwarmConfirmationResolution::InputClosed => { - mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; - stable_since = None; - continue; - } - SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), - SwarmConfirmationResolution::None => {} - } - } - let pending_interactions = - swarm_unhandled_interaction_reasons(parent_agent_id, &runtimes, input_closed); - if !pending_interactions.is_empty() { - observer.close_response_line(output)?; - return build_incomplete_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - pending_interactions, - ); - } - let failure_scan = - scan_swarm_terminal_failures_at(root, parent_agent_id, session_id, &runtimes); - if !failure_scan.reconciliation_agents.is_empty() { - observer.close_response_line(output)?; - return build_reconciliation_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - failure_scan.reconciliation_agents, - ); - } - if !failure_scan.failed_agents.is_empty() { - observer.close_response_line(output)?; - return build_failed_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - failure_scan.failed_agents, - ); - } - if !failure_scan.incomplete_reasons.is_empty() { - observer.close_response_line(output)?; - return build_incomplete_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - failure_scan.incomplete_reasons, - ); - } - if last_runner_check.elapsed() >= Duration::from_secs(2) { - let runner = read_external_agent_runner_status(); - last_runner_check = Instant::now(); - if runtimes_are_busy(&runtimes) && (!runner.enabled || !runner.running) { - reconciliation.push("external-runner".to_string()); - observer.close_response_line(output)?; - return build_reconciliation_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - reconciliation, - ); - } - } - if runtimes_are_busy(&runtimes) { - stable_since = None; - recovery_scan_required = true; - } else { - let since = stable_since.get_or_insert_with(Instant::now); - if since.elapsed() >= settle_window { - if recovery_scan_required { - observer.close_response_line(output)?; - writeln!( - output, - "[收束] Runtime 已空闲,检查待发布的 receipt / join。" - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - output - .flush() - .map_err(|error| format!("刷新终端失败:{error}"))?; - resume_game_creator_agent_background_tasks_at(root)?; - recovery_scan_required = false; - stable_since = Some(Instant::now()); - continue; - } - let conversation_metrics = read_turn_conversation_metrics( - root, - parent_agent_id, - session_id, - &conversation_baseline, - )?; - let parent_runtime = swarm_parent_runtime(parent_agent_id, session_id, &runtimes); - let completion_blockers = parent_runtime - .map(|parent| swarm_parent_completion_contract_blockers_at(root, parent)) - .unwrap_or_else(|| vec!["parent-runtime-missing".to_string()]); - match classify_swarm_turn_terminal( - parent_runtime, - conversation_metrics, - 0, - 0, - completion_blockers.len(), - ) { - SwarmTurnTerminalClassification::Settled => { - let printed_metrics = print_new_parent_reply( - root, - parent_agent_id, - session_id, - &conversation_baseline, - output, - observer, - )?; - if printed_metrics != conversation_metrics { - return build_incomplete_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - vec!["conversation-changed-before-settle".to_string()], - ); - } - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::Settled, - parent_agent_id, - session_id, - &runtimes, - conversation_metrics, - 0, - ); - return Ok(SwarmTurnOutcome::Settled(report)); - } - SwarmTurnTerminalClassification::Failed => { - return build_failed_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - vec![format!( - "{}:{}", - parent_agent_id, - parent_runtime - .map(|runtime| runtime.state.phase.as_str()) - .unwrap_or("missing") - )], - ); - } - SwarmTurnTerminalClassification::Incomplete => { - let mut reasons = completion_blockers; - append_swarm_terminal_snapshot_reasons( - &mut reasons, - parent_runtime, - conversation_metrics, - ); - return build_incomplete_turn_outcome( - root, - parent_agent_id, - session_id, - &conversation_baseline, - &runtimes, - reasons, - ); - } - } - } - } - if input_closed { - std::thread::sleep(poll_interval); - continue; - } - match input.recv_timeout(poll_interval) { - Ok(SwarmInputEvent::Line(line)) => { - let Some(command) = parse_swarm_chat_input(&line) else { - continue; - }; - observer.close_response_line(output)?; - match command { - SwarmChatInput::Quit => return Ok(SwarmTurnOutcome::Quit), - SwarmChatInput::Help => print_swarm_chat_help(output)?, - SwarmChatInput::Agents => print_swarm_agents(root, output)?, - SwarmChatInput::Status => print_swarm_status(root, output)?, - SwarmChatInput::History => { - print_conversation_history(root, parent_agent_id, output)? - } - SwarmChatInput::Compact => { - handle_swarm_context_compaction(root, parent_agent_id, output)? - } - SwarmChatInput::Mcp => print_swarm_mcp_status(root, output)?, - SwarmChatInput::Goal(command) => { - let _ = handle_swarm_goal_command(root, parent_agent_id, command, output)?; - stable_since = None; - recovery_scan_required = true; - } - SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, - SwarmChatInput::Message(message) => { - if let Some(parent) = runtimes.iter().find(|runtime| { - runtime.state.agent_id == parent_agent_id - && matches!(runtime.state.status.as_str(), "pending" | "running") - }) { - let steer_id = format!("swarm-steer-{}", unix_millis()); - let result = steer_game_creator_agent_runtime_task( - root.display().to_string(), - parent_agent_id.to_string(), - parent.state.session_id.clone(), - parent.state.run_id.clone(), - steer_id.clone(), - message, - )?; - writeln!( - output, - "[已追加] run={} steer={} providerInterrupted={}", - parent.state.run_id, steer_id, result.provider_interrupted - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } else { - writeln!(output, "[暂未发送] 子 Agent 尚未收束,请稍后重发。") - .map_err(|error| format!("写入终端失败:{error}"))?; - } - stable_since = None; - recovery_scan_required = true; - } - } - } - Ok(SwarmInputEvent::Eof) | Err(RecvTimeoutError::Disconnected) => { - mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; - } - Ok(SwarmInputEvent::Error(error)) => { - observer.close_response_line(output)?; - return Err(format!("读取终端输入失败:{error}")); - } - Err(RecvTimeoutError::Timeout) => {} - } - } -} - -fn runtimes_are_busy(runtimes: &[AgentRuntimeResult]) -> bool { - runtimes.iter().any(runtime_is_busy) -} - -fn runtime_is_busy(runtime: &AgentRuntimeResult) -> bool { - matches!( - runtime.state.status.as_str(), - "pending" - | "running" - | "waiting-for-confirmation" - | "waiting-for-user-input" - | "cancelling" - ) || runtime.state.phase == "needs-reconciliation" - || runtime.task_queue.pending > 0 - || runtime.task_queue.running > 0 - || runtime.task_queue.waiting_for_confirmation > 0 - || runtime.task_queue.waiting_for_user_input > 0 -} - -fn mark_swarm_turn_input_closed( - input_closed: &mut bool, - observer: &mut SwarmRuntimeObserver, - output: &mut W, -) -> Result<(), String> { - if *input_closed { - return Ok(()); - } - *input_closed = true; - observer.close_response_line(output)?; - writeln!(output, "[输入已关闭] 当前 turn 继续运行,等待可信终态。") - .map_err(|error| format!("写入终端失败:{error}")) -} - -fn swarm_parent_runtime<'a>( - parent_agent_id: &str, - session_id: &str, - runtimes: &'a [AgentRuntimeResult], -) -> Option<&'a AgentRuntimeResult> { - runtimes.iter().find(|runtime| { - runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id - }) -} - -fn runtime_terminal_failure_kind(runtime: &AgentRuntimeResult) -> Option<&'static str> { - if runtime.state.phase == "needs-reconciliation" { - None - } else if runtime.state.phase == "budget-exhausted" { - Some("budget-exhausted") - } else if runtime.state.status == "cancelled" || runtime.state.phase == "cancelled" { - Some("cancelled") - } else if runtime.state.status == "failed" { - Some("failed") - } else { - None - } -} - -fn parent_runtime_is_active(runtime: &AgentRuntimeResult) -> bool { - runtime.state.phase != "needs-reconciliation" - && (matches!( - runtime.state.status.as_str(), - "pending" - | "running" - | "waiting-for-confirmation" - | "waiting-for-user-input" - | "cancelling" - ) || runtime.recent_tasks.iter().any(|task| { - task.run_id == runtime.state.run_id - && matches!( - task.status.as_str(), - "pending" - | "running" - | "waiting-for-confirmation" - | "waiting-for-user-input" - | "cancelling" - ) - })) -} - -fn static_delegate_delivery_has_repairable_contract( - delivery: &StaticDelegateDeliveryRecord, -) -> bool { - delivery.repair_of_delegation_id.is_none() - && delivery.status != StaticDelegateDeliveryStatus::Suppressed - && (!delivery.acceptance_criteria.is_empty() || !delivery.expected_artifacts.is_empty()) - && delivery.structured_result.as_ref().is_none_or(|result| { - result.contract_status == StaticDelegateContractStatus::NeedsRepair - }) -} - -fn classify_failed_specialist( - parent: &AgentRuntimeResult, - child: &AgentRuntimeResult, - delivery: Option<&StaticDelegateDeliveryRecord>, - successful_repair: bool, -) -> SwarmSpecialistFailureDisposition { - let delivery_matches = delivery.is_some_and(|delivery| { - child.state.source == "agent-delegate" - && child.state.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) - && child.state.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) - && child.state.delegation_id.as_deref() == Some(delivery.delegation_id.as_str()) - && delivery.parent_agent_id == parent.state.agent_id - && delivery.parent_session_id == parent.state.session_id - && delivery.parent_run_id == parent.state.run_id - && delivery.target_agent_id == child.state.agent_id - && delivery.target_session_id == child.state.session_id - && delivery.target_run_id == child.state.run_id - }); - if !delivery_matches { - return SwarmSpecialistFailureDisposition::Failed; - } - let delivery = delivery.expect("matching delivery exists"); - if !static_delegate_delivery_has_repairable_contract(delivery) { - return SwarmSpecialistFailureDisposition::Failed; - } - if successful_repair { - return SwarmSpecialistFailureDisposition::Recoverable; - } - if parent_runtime_is_active(parent) { - SwarmSpecialistFailureDisposition::Recoverable - } else { - SwarmSpecialistFailureDisposition::Incomplete - } -} - -fn original_delivery_has_successful_repair( - original: &StaticDelegateDeliveryRecord, - claimed_deliveries: &[StaticDelegateDeliveryRecord], -) -> bool { - original.repair_of_delegation_id.is_none() - && claimed_deliveries.iter().any(|candidate| { - candidate.repair_of_delegation_id.as_deref() == Some(original.delegation_id.as_str()) - && candidate.terminal_status.as_deref() == Some("completed") - && candidate.structured_result.as_ref().is_some_and(|result| { - result.contract_status == StaticDelegateContractStatus::EvidenceReady - }) - }) -} - -fn scan_swarm_terminal_failures_at( - root: &Path, - parent_agent_id: &str, - session_id: &str, - runtimes: &[AgentRuntimeResult], -) -> SwarmTerminalFailureScan { - let mut scan = SwarmTerminalFailureScan::default(); - let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, runtimes) else { - return scan; - }; - let claimed_deliveries = match claimed_static_delegate_deliveries_at( - root, - &parent.state.agent_id, - &parent.state.run_id, - ) { - Ok(deliveries) => deliveries, - Err(_) => { - scan.reconciliation_agents - .push(parent.state.agent_id.clone()); - return scan; - } - }; - if let Some(kind) = runtime_terminal_failure_kind(parent) { - scan.failed_agents - .push(format!("{}:{kind}", parent.state.agent_id)); - } - for child in runtimes.iter().filter(|runtime| { - runtime.state.source == "agent-delegate" - && runtime.state.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) - && runtime.state.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) - && runtime_terminal_failure_kind(runtime).is_some() - }) { - let Some(delegation_id) = child - .state - .delegation_id - .as_deref() - .filter(|value| !value.is_empty()) - else { - scan.failed_agents.push(format!( - "{}:{}", - child.state.agent_id, - runtime_terminal_failure_kind(child).unwrap_or("failed") - )); - continue; - }; - let delivery = match read_static_delegate_delivery_at(root, delegation_id) { - Ok(Some(delivery)) => delivery, - Ok(None) | Err(_) => { - scan.reconciliation_agents - .push(child.state.agent_id.clone()); - continue; - } - }; - let successful_repair = - original_delivery_has_successful_repair(&delivery, &claimed_deliveries); - match classify_failed_specialist(parent, child, Some(&delivery), successful_repair) { - SwarmSpecialistFailureDisposition::Recoverable => {} - SwarmSpecialistFailureDisposition::Failed => scan.failed_agents.push(format!( - "{}:{}", - child.state.agent_id, - runtime_terminal_failure_kind(child).unwrap_or("failed") - )), - SwarmSpecialistFailureDisposition::Incomplete => scan - .incomplete_reasons - .push(format!("repair-required:{}", child.state.agent_id)), - } - } - scan.failed_agents.sort(); - scan.failed_agents.dedup(); - scan.incomplete_reasons.sort(); - scan.incomplete_reasons.dedup(); - scan.reconciliation_agents.sort(); - scan.reconciliation_agents.dedup(); - scan -} - -fn swarm_unhandled_interaction_reasons( - parent_agent_id: &str, - runtimes: &[AgentRuntimeResult], - input_closed: bool, -) -> Vec { - let mut reasons = Vec::new(); - for runtime in runtimes { - let waiting_for_confirmation = runtime.state.status == "waiting-for-confirmation" - || runtime.state.pending_tool_action.is_some() - || runtime.task_queue.waiting_for_confirmation > 0; - let waiting_for_user_input = runtime.state.status == "waiting-for-user-input" - || runtime.user_input_request.is_some() - || runtime.task_queue.waiting_for_user_input > 0; - if input_closed && waiting_for_confirmation { - reasons.push(format!("pending-confirmation:{}", runtime.state.agent_id)); - } - if waiting_for_user_input && (input_closed || runtime.state.agent_id != parent_agent_id) { - reasons.push(format!("pending-user-input:{}", runtime.state.agent_id)); - } - } - reasons.sort(); - reasons.dedup(); - reasons -} - -fn parent_runtime_completed(runtime: &AgentRuntimeResult) -> bool { - runtime.state.phase == "completed" - && matches!(runtime.state.status.as_str(), "idle" | "completed") -} - -fn classify_swarm_turn_terminal( - parent: Option<&AgentRuntimeResult>, - conversation_metrics: SwarmTurnConversationMetrics, - failed_runtime_count: usize, - pending_interaction_count: usize, - completion_blocker_count: usize, -) -> SwarmTurnTerminalClassification { - if failed_runtime_count > 0 - || parent.is_some_and(|runtime| runtime_terminal_failure_kind(runtime).is_some()) - { - return SwarmTurnTerminalClassification::Failed; - } - if parent.is_none_or(|runtime| !parent_runtime_completed(runtime)) - || pending_interaction_count > 0 - || completion_blocker_count > 0 - || conversation_metrics.new_assistant_message_count != 1 - || conversation_metrics.final_reply_chars == 0 - { - return SwarmTurnTerminalClassification::Incomplete; - } - SwarmTurnTerminalClassification::Settled -} - -fn append_swarm_terminal_snapshot_reasons( - reasons: &mut Vec, - parent: Option<&AgentRuntimeResult>, - conversation_metrics: SwarmTurnConversationMetrics, -) { - match parent { - None => reasons.push("parent-runtime-missing".to_string()), - Some(parent) if !parent_runtime_completed(parent) => reasons.push(format!( - "parent-not-completed:{}:{}", - parent.state.status, parent.state.phase - )), - Some(_) => {} - } - if conversation_metrics.new_assistant_message_count != 1 { - reasons.push(format!( - "assistant-count={}", - conversation_metrics.new_assistant_message_count - )); - } else if conversation_metrics.final_reply_chars == 0 { - reasons.push("assistant-empty".to_string()); - } - reasons.sort(); - reasons.dedup(); -} - -fn swarm_parent_completion_contract_blockers_at( - root: &Path, - parent: &AgentRuntimeResult, -) -> Vec { - let mut blockers = Vec::new(); - let agent_id = parent.state.agent_id.as_str(); - let run_id = parent.state.run_id.as_str(); - if let Some(blocker) = structured_plan_completion_blocker(&parent.state) { - blockers.push(blocker.tool); - } - if parent.state.goal_id.is_some() - && !matches!( - parent.state.goal_status.as_deref(), - Some(AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED) - ) - { - blockers.push("runtime.goal".to_string()); - } - if parent.state.pending_tool_action.is_some() { - blockers.push("runtime.pending_tool_action".to_string()); - } - match super::provider_retry::read_for_run_at(root, agent_id, run_id) { - Ok(None) => {} - Ok(Some(_)) | Err(_) => blockers.push("runtime.provider_retry".to_string()), - } - let provider_action_batch_path = - game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id); - if provider_action_batch_path.exists() - || agent_runtime_json_sidecar_backup_path(&provider_action_batch_path).exists() - { - blockers.push("runtime.provider_action_batch".to_string()); - } - let finalization_path = game_creator_agent_runtime_finalization_path(root, agent_id, run_id); - if finalization_path.exists() - || agent_runtime_json_sidecar_backup_path(&finalization_path).exists() - { - blockers.push("runtime.finalization".to_string()); - } - if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { - Ok(resolution) => { - match read_supervisor_collaboration_state_at(root, agent_id, run_id) { - Ok(state) => { - if supervisor_collaboration_completion_gap(&resolution.policy, &state) - .is_some() - { - blockers.push("runtime.collaboration_policy".to_string()); - } - } - Err(_) => blockers.push("runtime.collaboration_policy".to_string()), - } - } - Err(_) => blockers.push("runtime.collaboration_policy".to_string()), - } - } - if let Some(blocker) = process_session_completion_blocker_at(root, agent_id, run_id) { - blockers.push(blocker.tool); - } - if let Some(blocker) = isolated_join_completion_blocker_at(root, agent_id, run_id) { - blockers.push(blocker.tool); - } - if let Some(blocker) = static_delegate_completion_blocker_at(root, agent_id, run_id) { - blockers.push(blocker.tool); - } - if let Some(blocker) = project_verification_completion_blocker_at(root, agent_id, run_id, &[]) { - blockers.push(blocker.tool); - } - blockers.sort(); - blockers.dedup(); - blockers -} - -fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { - runtimes - .iter() - .filter(|runtime| { - runtime.state.phase == "needs-reconciliation" - || (runtime.state.status == "waiting-for-confirmation" - && runtime.state.pending_tool_action.is_none()) - || (runtime.state.status == "waiting-for-user-input" - && runtime.user_input_request.is_none()) - }) - .map(|runtime| runtime.state.agent_id.clone()) - .collect() -} - -fn build_reconciliation_turn_outcome( - root: &Path, - parent_agent_id: &str, - session_id: &str, - conversation_baseline: &SwarmTurnConversationBaseline, - runtimes: &[AgentRuntimeResult], - mut agent_ids: Vec, -) -> Result { - agent_ids.sort(); - agent_ids.dedup(); - let conversation_metrics = - read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::NeedsReconciliation, - parent_agent_id, - session_id, - runtimes, - conversation_metrics, - agent_ids.len(), - ); - Ok(SwarmTurnOutcome::NeedsReconciliation { agent_ids, report }) -} - -fn build_failed_turn_outcome( - root: &Path, - parent_agent_id: &str, - session_id: &str, - conversation_baseline: &SwarmTurnConversationBaseline, - runtimes: &[AgentRuntimeResult], - mut agent_ids: Vec, -) -> Result { - agent_ids.sort(); - agent_ids.dedup(); - let conversation_metrics = - read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::Failed, - parent_agent_id, - session_id, - runtimes, - conversation_metrics, - 0, - ); - Ok(SwarmTurnOutcome::Failed { agent_ids, report }) -} - -fn build_incomplete_turn_outcome( - root: &Path, - parent_agent_id: &str, - session_id: &str, - conversation_baseline: &SwarmTurnConversationBaseline, - runtimes: &[AgentRuntimeResult], - mut reasons: Vec, -) -> Result { - reasons.sort(); - reasons.dedup(); - if reasons.is_empty() { - reasons.push("terminal-contract-not-proven".to_string()); - } - let conversation_metrics = - read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::Incomplete, - parent_agent_id, - session_id, - runtimes, - conversation_metrics, - 0, - ); - Ok(SwarmTurnOutcome::Incomplete { reasons, report }) -} - -fn new_swarm_turn_conversation_baseline( - previous_message_count: usize, - parent_run_id: impl Into, -) -> SwarmTurnConversationBaseline { - SwarmTurnConversationBaseline { - previous_message_count, - parent_run_id: parent_run_id.into(), - recovered_assistant: None, - } -} - -fn capture_recovered_swarm_assistant_at( - root: &Path, - parent_agent_id: &str, - session_id: &str, - baseline: &mut SwarmTurnConversationBaseline, -) -> Result<(), String> { - if baseline.parent_run_id.trim().is_empty() || baseline.recovered_assistant.is_some() { - return Ok(()); - } - let conversation = - read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; - if baseline.previous_message_count > conversation.messages.len() { - return Err("Swarm turn 对话 baseline 超出当前 Session 消息数".to_string()); - } - if baseline.previous_message_count < conversation.messages.len() { - return Ok(()); - } - let Some(journal) = read_game_creator_agent_runtime_finalization_journal( - root, - parent_agent_id, - &baseline.parent_run_id, - )? - else { - return Ok(()); - }; - if journal.agent_id != parent_agent_id - || journal.session_id != session_id - || journal.run_id != baseline.parent_run_id - { - return Err("Swarm 恢复 finalization 与目标 parent Session/run 不匹配".to_string()); - } - if !game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { - return Ok(()); - } - baseline.recovered_assistant = Some(SwarmRecoveredAssistant { - run_id: journal.run_id, - finalization_id: journal.finalization_id, - message_id: journal.message_id, - content: journal.response, - }); - Ok(()) -} - -fn read_turn_conversation_snapshot( - root: &Path, - parent_agent_id: &str, - session_id: &str, - baseline: &SwarmTurnConversationBaseline, -) -> Result { - let conversation = - read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; - if baseline.previous_message_count > conversation.messages.len() { - return Err("Swarm turn 对话 baseline 超出当前 Session 消息数".to_string()); - } - let (mut metrics, final_reply) = summarize_new_assistant_messages( - conversation - .messages - .iter() - .skip(baseline.previous_message_count) - .map(|message| (message.role.as_str(), message.content.as_str())), - ); - let mut final_reply = final_reply.map(str::to_string); - let recovered_before_observation = baseline.recovered_assistant.is_some(); - if let Some(recovered) = baseline.recovered_assistant.as_ref() { - if recovered.run_id != baseline.parent_run_id - || recovered.finalization_id.trim().is_empty() - || recovered.message_id.trim().is_empty() - { - return Err("Swarm 恢复 assistant 身份不完整".to_string()); - } - if metrics.new_assistant_message_count != 0 { - return Err("Swarm 恢复 assistant 与 baseline 后的新回复重叠".to_string()); - } - metrics.new_assistant_message_count = metrics.new_assistant_message_count.saturating_add(1); - if final_reply.is_none() { - metrics.final_reply_chars = recovered.content.chars().count(); - final_reply = Some(recovered.content.clone()); - } - } - Ok(SwarmTurnConversationSnapshot { - metrics, - final_reply, - recovered_before_observation, - }) -} - -fn read_turn_conversation_metrics( - root: &Path, - parent_agent_id: &str, - session_id: &str, - baseline: &SwarmTurnConversationBaseline, -) -> Result { - read_turn_conversation_snapshot(root, parent_agent_id, session_id, baseline) - .map(|snapshot| snapshot.metrics) -} - -fn summarize_new_assistant_messages<'a>( - messages: impl IntoIterator, -) -> (SwarmTurnConversationMetrics, Option<&'a str>) { - let mut new_assistant_message_count = 0; - let mut final_reply = None; - for (role, content) in messages { - if role == "assistant" { - new_assistant_message_count += 1; - final_reply = Some(content); - } - } - ( - SwarmTurnConversationMetrics { - new_assistant_message_count, - final_reply_chars: final_reply.map_or(0, |reply| reply.chars().count()), - }, - final_reply, - ) -} - -fn build_swarm_turn_report( - outcome: SwarmTurnReportOutcome, - parent_agent_id: &str, - session_id: &str, - runtimes: &[AgentRuntimeResult], - conversation_metrics: SwarmTurnConversationMetrics, - reconciliation_agent_count: usize, -) -> SwarmTurnReport { - let parent_run_id = runtimes - .iter() - .find(|runtime| { - runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id - }) - .map(|runtime| runtime.state.run_id.trim()) - .filter(|run_id| !run_id.is_empty()) - .map(str::to_string); - SwarmTurnReport { - schema_version: SWARM_TURN_REPORT_SCHEMA_VERSION, - outcome, - parent_agent_id: parent_agent_id.to_string(), - session_id: session_id.to_string(), - parent_run_id, - runtime_count: runtimes.len(), - busy_runtime_count: runtimes - .iter() - .filter(|runtime| runtime_is_busy(runtime)) - .count(), - pending_task_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.pending)) - .sum(), - running_task_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.running)) - .sum(), - waiting_for_confirmation_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.waiting_for_confirmation)) - .sum(), - waiting_for_user_input_count: runtimes - .iter() - .map(|runtime| u64::from(runtime.task_queue.waiting_for_user_input)) - .sum(), - new_assistant_message_count: conversation_metrics.new_assistant_message_count, - final_reply_chars: conversation_metrics.final_reply_chars, - reconciliation_agent_count, - } -} - -fn print_new_parent_reply( - root: &Path, - parent_agent_id: &str, - session_id: &str, - baseline: &SwarmTurnConversationBaseline, - output: &mut W, - observer: &mut SwarmRuntimeObserver, -) -> Result { - let snapshot = read_turn_conversation_snapshot(root, parent_agent_id, session_id, baseline)?; - observer.close_response_line(output)?; - if snapshot.recovered_before_observation { - writeln!(output, "[本轮结束] 父 Agent 回复已在恢复前持久化。") - .map_err(|error| format!("写入终端失败:{error}"))?; - } else { - print_settled_parent_reply( - parent_agent_id, - session_id, - snapshot.final_reply.as_deref(), - observer, - output, - )?; - } - Ok(snapshot.metrics) -} - -fn print_settled_parent_reply( - parent_agent_id: &str, - session_id: &str, - reply: Option<&str>, - observer: &SwarmRuntimeObserver, - output: &mut W, -) -> Result<(), String> { - let Some(reply) = reply else { - return writeln!(output, "[本轮结束] 父 Agent 未产生新的最终回复。") - .map_err(|error| format!("写入终端失败:{error}")); - }; - if observer.parent_reply_was_fully_streamed(parent_agent_id, session_id, reply) { - writeln!(output, "[本轮结束] 父 Agent 回复已完整流式输出。") - .map_err(|error| format!("写入终端失败:{error}")) - } else { - writeln!(output, "\nAgent> {reply}").map_err(|error| format!("写入终端失败:{error}")) - } -} - -fn print_turn_outcome(outcome: SwarmTurnOutcome, output: &mut W) -> Result<(), String> { - match outcome { - SwarmTurnOutcome::Settled(report) => print_swarm_turn_report(&report, output), - SwarmTurnOutcome::Failed { agent_ids, report } => { - writeln!( - output, - "[已失败] 以下 Runtime 到达失败终态:{}", - agent_ids.join(", ") - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - print_swarm_turn_report(&report, output) - } - SwarmTurnOutcome::Incomplete { reasons, report } => { - writeln!( - output, - "[未完成] 当前 turn 未满足可信终态:{}", - reasons.join(", ") - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - print_swarm_turn_report(&report, output) - } - SwarmTurnOutcome::NeedsReconciliation { agent_ids, report } => { - writeln!( - output, - "[已阻断] 以下 Agent 需要人工 reconciliation:{}", - agent_ids.join(", ") - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - print_swarm_turn_report(&report, output) - } - SwarmTurnOutcome::Quit => Ok(()), - } -} - -fn print_swarm_turn_report( - report: &SwarmTurnReport, - output: &mut W, -) -> Result<(), String> { - let json = serde_json::to_string(report) - .map_err(|error| format!("序列化 turn report 失败:{error}"))?; - writeln!(output, "{SWARM_TURN_REPORT_PREFIX}{json}") - .map_err(|error| format!("写入终端失败:{error}")) -} - -impl SwarmResponseStreamIdentity { - fn from_stream(stream: &AgentRuntimeResponseStream) -> Self { - Self { - task_id: stream.task_id.clone(), - session_id: stream.session_id.clone(), - run_id: stream.run_id.clone(), - request_slot: stream.request_slot.clone(), - applied_steer_cursor: stream.applied_steer_cursor, - response_revision: stream.response_revision, - } - } -} - -impl SwarmResponseStreamCursor { - fn seeded(stream: &AgentRuntimeResponseStream) -> Self { - Self { - identity: SwarmResponseStreamIdentity::from_stream(stream), - session_id: stream.session_id.clone(), - sequence: stream.sequence, - status: stream.status.clone(), - accumulated_text: stream.accumulated_text.clone(), - printed_accumulated_text: stream.accumulated_text.is_empty().then(String::new), - connected: true, - rejected_snapshot: None, - } - } - - fn fresh(stream: &AgentRuntimeResponseStream) -> Self { - Self { - identity: SwarmResponseStreamIdentity::from_stream(stream), - session_id: stream.session_id.clone(), - sequence: stream.sequence, - status: stream.status.clone(), - accumulated_text: stream.accumulated_text.clone(), - printed_accumulated_text: None, - connected: true, - rejected_snapshot: None, - } - } -} - -impl SwarmRejectedResponseStreamSnapshot { - fn from_stream(stream: &AgentRuntimeResponseStream) -> Self { - Self { - session_id: stream.session_id.clone(), - sequence: stream.sequence, - status: stream.status.clone(), - accumulated_text: stream.accumulated_text.clone(), - } - } -} - -fn swarm_response_stream_is_printable(stream: &AgentRuntimeResponseStream) -> bool { - matches!( - stream.status.as_str(), - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY - ) -} - -fn swarm_response_stream_identity_reset_reason( - previous: &SwarmResponseStreamIdentity, - current: &SwarmResponseStreamIdentity, -) -> &'static str { - if previous.run_id != current.run_id { - "new-run" - } else if previous.session_id != current.session_id { - "new-session" - } else if previous.task_id != current.task_id { - "new-task" - } else if previous.applied_steer_cursor != current.applied_steer_cursor { - "new-steer-cursor" - } else if previous.request_slot != current.request_slot { - "new-request-slot" - } else { - "new-response-revision" - } -} - -impl SwarmRuntimeObserver { - fn seed(root: &Path) -> Result { - let mut observer = Self::default(); - for runtime in read_game_creator_agent_runtimes_at(root)? { - observer.state_signatures.insert( - runtime.state.agent_id.clone(), - runtime_state_signature(&runtime.state, &runtime.task_queue), - ); - if let Some(stream) = runtime.response_stream.as_ref() { - observer.response_streams.insert( - runtime.state.agent_id.clone(), - SwarmResponseStreamCursor::seeded(stream), - ); - } - for event in runtime.recent_events { - observer.seen_events.insert(runtime_event_key(&event)); - } - } - Ok(observer) - } - - fn print_changes( - &mut self, - runtimes: &[AgentRuntimeResult], - output: &mut W, - ) -> Result { - let mut changed = false; - for runtime in runtimes { - let has_runtime_history = !runtime.state.run_id.is_empty() - || runtime.task_queue.total > 0 - || !runtime.recent_events.is_empty(); - if has_runtime_history { - let signature = runtime_state_signature(&runtime.state, &runtime.task_queue); - if self.state_signatures.get(&runtime.state.agent_id) != Some(&signature) { - changed = true; - self.close_response_line(output)?; - self.state_signatures - .insert(runtime.state.agent_id.clone(), signature); - print_runtime_state(&runtime.state, &runtime.task_queue, output)?; - } - } - for event in &runtime.recent_events { - if self.seen_events.insert(runtime_event_key(event)) { - changed = true; - self.close_response_line(output)?; - writeln!( - output, - "[事件] {} {} {}/{} {}{}", - event.agent_id, - event.event_type, - event.status, - event.phase, - event.summary, - event - .detail - .as_deref() - .map(|detail| format!(" | {detail}")) - .unwrap_or_default() - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - } - changed |= self.observe_response_stream( - &runtime.state.agent_id, - runtime.response_stream.as_ref(), - output, - )?; - } - Ok(changed) - } - - fn observe_response_stream( - &mut self, - agent_id: &str, - stream: Option<&AgentRuntimeResponseStream>, - output: &mut W, - ) -> Result { - let previous = self.response_streams.remove(agent_id); - let Some(stream) = stream else { - let Some(mut cursor) = previous else { - return Ok(false); - }; - let changed = cursor.connected; - if changed { - cursor.connected = false; - if self.response_line_matches(agent_id, &cursor.identity) { - self.close_response_line(output)?; - } - } - self.response_streams.insert(agent_id.to_string(), cursor); - return Ok(changed); - }; - - let printable = swarm_response_stream_is_printable(stream); - let Some(previous) = previous else { - let reason = if stream.sequence == 0 && stream.accumulated_text.is_empty() { - "new-stream" - } else { - "reconnect" - }; - self.print_response_stream_reset(agent_id, stream, reason, output)?; - let mut cursor = SwarmResponseStreamCursor::fresh(stream); - if printable { - self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; - } - self.response_streams.insert(agent_id.to_string(), cursor); - return Ok(true); - }; - - let identity = SwarmResponseStreamIdentity::from_stream(stream); - if previous.identity != identity { - let reason = swarm_response_stream_identity_reset_reason(&previous.identity, &identity); - self.print_response_stream_reset(agent_id, stream, reason, output)?; - let mut cursor = SwarmResponseStreamCursor::fresh(stream); - if printable { - self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; - } - self.response_streams.insert(agent_id.to_string(), cursor); - return Ok(true); - } - - let exact_snapshot = previous.session_id == stream.session_id - && previous.sequence == stream.sequence - && previous.status == stream.status - && previous.accumulated_text == stream.accumulated_text; - let unchanged = previous.connected && exact_snapshot; - if unchanged { - self.response_streams.insert(agent_id.to_string(), previous); - return Ok(false); - } - - let non_monotonic_reason = if previous.session_id != stream.session_id { - Some("identity-conflict") - } else if stream.sequence < previous.sequence { - Some("sequence-rollback") - } else if stream.sequence == previous.sequence && !exact_snapshot { - Some("sequence-conflict") - } else { - None - }; - if let Some(reason) = non_monotonic_reason { - let rejected = SwarmRejectedResponseStreamSnapshot::from_stream(stream); - if previous.rejected_snapshot.as_ref() == Some(&rejected) { - self.response_streams.insert(agent_id.to_string(), previous); - return Ok(false); - } - self.print_response_stream_reset(agent_id, stream, reason, output)?; - let mut cursor = previous; - cursor.connected = false; - cursor.rejected_snapshot = Some(rejected); - self.response_streams.insert(agent_id.to_string(), cursor); - return Ok(true); - } - - let prefix_continuation = stream - .accumulated_text - .strip_prefix(&previous.accumulated_text); - let reset_reason = if !previous.connected { - Some("reconnect") - } else if prefix_continuation.is_none() { - Some("non-prefix-correction") - } else { - None - }; - if let Some(reason) = reset_reason { - self.print_response_stream_reset(agent_id, stream, reason, output)?; - } - - let mut cursor = SwarmResponseStreamCursor::fresh(stream); - if printable { - let previous_printed = previous.printed_accumulated_text.as_deref(); - let reset_requires_full = reset_reason.is_some_and(|reason| reason != "reconnect") - && previous_printed != Some(stream.accumulated_text.as_str()); - if previous_printed == Some(stream.accumulated_text.as_str()) { - cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); - } else if reset_requires_full { - self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; - } else if let Some(suffix) = prefix_continuation - .filter(|_| previous_printed == Some(previous.accumulated_text.as_str())) - { - self.write_response_stream_chunk(agent_id, &identity, suffix, output)?; - cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); - } else { - self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; - } - } else { - cursor.printed_accumulated_text = previous - .printed_accumulated_text - .filter(|printed| printed == &stream.accumulated_text); - if self.response_line_matches(agent_id, &identity) { - self.close_response_line(output)?; - } - } - self.response_streams.insert(agent_id.to_string(), cursor); - Ok(true) - } - - fn print_response_stream_full( - &mut self, - agent_id: &str, - stream: &AgentRuntimeResponseStream, - cursor: &mut SwarmResponseStreamCursor, - output: &mut W, - ) -> Result<(), String> { - self.write_response_stream_chunk( - agent_id, - &cursor.identity, - &stream.accumulated_text, - output, - )?; - cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); - Ok(()) - } - - fn print_response_stream_reset( - &mut self, - agent_id: &str, - stream: &AgentRuntimeResponseStream, - reason: &str, - output: &mut W, - ) -> Result<(), String> { - self.close_response_line(output)?; - writeln!( - output, - "[回复流重置] agent={} run={} requestSlot={} revision={} sequence={} status={} chars={} reason={}", - agent_id, - stream.run_id, - stream.request_slot, - stream.response_revision, - stream.sequence, - stream.status, - stream.accumulated_text.chars().count(), - reason - ) - .map_err(|error| format!("写入终端失败:{error}")) - } - - fn write_response_stream_chunk( - &mut self, - agent_id: &str, - identity: &SwarmResponseStreamIdentity, - chunk: &str, - output: &mut W, - ) -> Result<(), String> { - if chunk.is_empty() { - return Ok(()); - } - if !self.response_line_matches(agent_id, identity) { - self.close_response_line(output)?; - write!(output, "Agent[{agent_id}]> {chunk}") - .map_err(|error| format!("写入终端失败:{error}"))?; - self.open_response_line = Some(SwarmResponseStreamLine { - agent_id: agent_id.to_string(), - identity: identity.clone(), - }); - } else { - write!(output, "{chunk}").map_err(|error| format!("写入终端失败:{error}"))?; - } - output - .flush() - .map_err(|error| format!("刷新终端失败:{error}")) - } - - fn response_line_matches( - &self, - agent_id: &str, - identity: &SwarmResponseStreamIdentity, - ) -> bool { - self.open_response_line - .as_ref() - .is_some_and(|line| line.agent_id == agent_id && line.identity == *identity) - } - - fn close_response_line(&mut self, output: &mut W) -> Result<(), String> { - if self.open_response_line.take().is_some() { - writeln!(output).map_err(|error| format!("写入终端失败:{error}"))?; - output - .flush() - .map_err(|error| format!("刷新终端失败:{error}"))?; - } - Ok(()) - } - - fn parent_reply_was_fully_streamed( - &self, - parent_agent_id: &str, - session_id: &str, - reply: &str, - ) -> bool { - self.response_streams - .get(parent_agent_id) - .is_some_and(|cursor| { - cursor.session_id == session_id - && cursor.accumulated_text == reply - && cursor.printed_accumulated_text.as_deref() == Some(reply) - && matches!( - cursor.status.as_str(), - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING - | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY - | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED - ) - }) - } - - fn resolve_confirmations( - &mut self, - root: &Path, - parent_agent_id: &str, - runtimes: &[AgentRuntimeResult], - input: &Receiver, - output: &mut W, - ) -> Result { - for runtime in runtimes { - if runtime.state.status != "waiting-for-confirmation" { - continue; - } - let Some(pending) = runtime.state.pending_tool_action.as_ref() else { - continue; - }; - let key = format!( - "{}:{}:{}", - runtime.state.agent_id, runtime.state.run_id, pending.action_id - ); - if self.handled_confirmations.contains(&key) { - continue; - } - self.close_response_line(output)?; - writeln!( - output, - "\n[待确认] agent={} run={} action={} tool={}", - runtime.state.agent_id, runtime.state.run_id, pending.action_id, pending.tool - ) - .and_then(|_| { - if let Some(summary) = pending.input_summary.as_deref() { - writeln!(output, "输入摘要:{summary}") - } else { - Ok(()) - } - }) - .and_then(|_| write!(output, "输入 approve 或 reject:")) - .map_err(|error| format!("写入终端失败:{error}"))?; - let decision = prompt_swarm_decision(root, parent_agent_id, input, output, "")?; - let approved = match decision { - SwarmPromptDecision::Approve => true, - SwarmPromptDecision::Reject => false, - SwarmPromptDecision::Deferred => return Ok(SwarmConfirmationResolution::Handled), - SwarmPromptDecision::InputClosed => { - return Ok(SwarmConfirmationResolution::InputClosed) - } - SwarmPromptDecision::Quit => return Ok(SwarmConfirmationResolution::Quit), - }; - let project_path = root.display().to_string(); - if approved { - confirm_game_creator_agent_runtime_task( - project_path, - runtime.state.agent_id.clone(), - runtime.state.run_id.clone(), - pending.action_id.clone(), - "Agent Swarm Chat 终端批准".to_string(), - )?; - writeln!(output, "[已批准] {}", pending.action_id) - .map_err(|error| format!("写入终端失败:{error}"))?; - } else { - reject_game_creator_agent_runtime_task( - project_path, - runtime.state.agent_id.clone(), - runtime.state.run_id.clone(), - pending.action_id.clone(), - "Agent Swarm Chat 终端拒绝".to_string(), - )?; - writeln!(output, "[已拒绝] {}", pending.action_id) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - self.handled_confirmations.insert(key); - return Ok(SwarmConfirmationResolution::Handled); - } - Ok(SwarmConfirmationResolution::None) - } - - fn resolve_user_input_requests( - &mut self, - root: &Path, - parent_agent_id: &str, - runtimes: &[AgentRuntimeResult], - input: &Receiver, - output: &mut W, - ) -> Result { - for runtime in runtimes { - let Some(request) = runtime.user_input_request.as_ref() else { - continue; - }; - if runtime.state.agent_id != parent_agent_id - || runtime.state.status != "waiting-for-user-input" - { - continue; - } - let key = format!( - "{}:{}:{}", - runtime.state.agent_id, runtime.state.run_id, request.request_id - ); - if self.handled_user_input_requests.contains(&key) { - continue; - } - self.close_response_line(output)?; - writeln!( - output, - "\n[Needs input] agent={} run={} request={}", - runtime.state.agent_id, runtime.state.run_id, request.request_id - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - let mut answers = BTreeMap::new(); - for question in &request.questions { - writeln!(output, "\n{}:{}", question.header, question.question) - .map_err(|error| format!("写入终端失败:{error}"))?; - for (index, option) in question.options.iter().enumerate() { - writeln!( - output, - " {}. {} - {}", - index + 1, - option.label, - option.description - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - loop { - write!( - output, - "请选择 1-{},或直接输入其他答案:", - question.options.len() - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - output - .flush() - .map_err(|error| format!("刷新终端失败:{error}"))?; - let Some(line) = receive_swarm_chat_line(input)? else { - return Ok(SwarmConfirmationResolution::InputClosed); - }; - if matches!(line.as_str(), "/quit" | "/exit") { - return Ok(SwarmConfirmationResolution::Quit); - } - if line == "/status" { - print_swarm_status(root, output)?; - continue; - } - if line == "/history" { - print_conversation_history(root, parent_agent_id, output)?; - continue; - } - let answer = line - .parse::() - .ok() - .and_then(|index| index.checked_sub(1)) - .and_then(|index| question.options.get(index)) - .map(|option| option.label.clone()) - .unwrap_or_else(|| line.trim().to_string()); - if answer.is_empty() { - writeln!(output, "回答不能为空。") - .map_err(|error| format!("写入终端失败:{error}"))?; - continue; - } - answers.insert(question.id.clone(), answer); - break; - } - } - let response_id = self - .user_input_response_ids - .entry(key.clone()) - .or_insert_with(|| { - format!("swarm-user-input-{}-{}", request.request_id, unix_millis()) - }) - .clone(); - answer_game_creator_agent_runtime_user_input_at( - root, - &runtime.state.agent_id, - &runtime.state.run_id, - &request.action_id, - &request.request_id, - &response_id, - answers, - )?; - writeln!(output, "[已回答] {}", request.request_id) - .map_err(|error| format!("写入终端失败:{error}"))?; - self.handled_user_input_requests.insert(key); - return Ok(SwarmConfirmationResolution::Handled); - } - Ok(SwarmConfirmationResolution::None) - } -} - -fn print_runtime_state( - state: &AgentRuntimeState, - queue: &AgentRuntimeTaskQueueSummary, - output: &mut W, -) -> Result<(), String> { - let relation = state - .parent_agent_id - .as_deref() - .map(|parent| { - format!( - " parent={parent} delegation={}", - state.delegation_id.as_deref().unwrap_or("unknown") - ) - }) - .unwrap_or_default(); - writeln!( - output, - "[状态] {} {}/{} run={} queue={}/{}/{}/{}{} | {}", - state.agent_id, - state.status, - state.phase, - state.run_id, - queue.pending, - queue.running, - queue.waiting_for_confirmation, - queue.waiting_for_user_input, - relation, - state.current_action - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - - let completed = state - .plan_steps - .iter() - .filter(|step| step.status == "completed") - .count(); - let current_step = runtime_current_plan_step(state) - .map(|step| { - format!( - "#{} [{}] {}", - step.index.saturating_add(1), - runtime_cli_value(&step.status), - runtime_cli_value(&step.title) - ) - }) - .unwrap_or_else(|| "-".to_string()); - writeln!( - output, - "[计划] revision={} completed={}/{} current={} | waiting={} | next={}", - state.plan_revision, - completed, - state.plan_steps.len(), - current_step, - runtime_cli_value(&state.waiting_on), - runtime_cli_value(&state.next_step) - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - if !state.plan_explanation.trim().is_empty() { - writeln!( - output, - " [计划说明] {}", - runtime_cli_value(&state.plan_explanation) - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - writeln!( - output, - "[上下文] estimated={}/{} actual={}/{}/{} compaction={} last={}", - state.context_usage.estimated_input_tokens, - state.context_usage.auto_compact_token_limit, - state - .context_usage - .last_prompt_tokens - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - state - .context_usage - .last_completion_tokens - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - state - .context_usage - .last_total_tokens - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - state.context_usage.compaction_revision, - state - .context_usage - .last_compacted_at - .map(|value| value.to_string()) - .unwrap_or_else(|| "-".to_string()), - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - - for step in state.plan_steps.iter().take(SWARM_CHAT_PLAN_STEP_LIMIT) { - writeln!( - output, - " [计划步骤] #{} [{}] {}", - step.index.saturating_add(1), - runtime_cli_value(&step.status), - runtime_cli_value(&step.title) - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - if state.plan_steps.len() > SWARM_CHAT_PLAN_STEP_LIMIT { - writeln!( - output, - " [计划] 另有 {} 条步骤未显示", - state.plan_steps.len() - SWARM_CHAT_PLAN_STEP_LIMIT - ) - .map_err(|error| format!("写入终端失败:{error}"))?; - } - Ok(()) -} - -fn runtime_current_plan_step(state: &AgentRuntimeState) -> Option<&AgentRuntimePlanStep> { - state - .active_plan_step_index - .and_then(|active_index| { - state - .plan_steps - .iter() - .find(|step| step.index == active_index) - .or_else(|| state.plan_steps.get(active_index as usize)) - }) - .or_else(|| { - state.plan_steps.iter().find(|step| { - matches!( - step.status.as_str(), - "active" | "in_progress" | "running" | "waiting-for-confirmation" - ) - }) - }) - .or_else(|| { - state - .plan_steps - .iter() - .find(|step| step.status == "pending") - }) -} - -fn runtime_cli_value(value: &str) -> &str { - let value = value.trim(); - if value.is_empty() { - "-" - } else { - value - } -} - -fn runtime_state_signature( - state: &AgentRuntimeState, - queue: &AgentRuntimeTaskQueueSummary, -) -> String { - let completed_plan_steps = state - .plan_steps - .iter() - .filter(|step| step.status == "completed") - .count(); - let current_plan_step = runtime_current_plan_step(state) - .map(|step| format!("{}:{}:{}", step.index, step.status, step.title)) - .unwrap_or_default(); - let mut signature = format!( - "{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", - state.run_id, - state.status, - state.phase, - state.current_action, - state.updated_at, - queue.pending, - queue.running, - queue.waiting_for_confirmation, - queue.waiting_for_user_input, - queue.updated_at, - state.plan_revision, - state - .active_plan_step_index - .map(|index| index.to_string()) - .unwrap_or_default(), - completed_plan_steps, - state.plan_steps.len(), - current_plan_step, - state.waiting_on, - state.next_step - ); - signature.push(':'); - signature.push_str(&state.plan_explanation); - signature.push(':'); - signature.push_str(&format!( - "{}:{}:{:?}:{:?}:{}:{:?}", - state.context_usage.estimated_input_tokens, - state.context_usage.auto_compact_token_limit, - state.context_usage.last_prompt_tokens, - state.context_usage.last_completion_tokens, - state.context_usage.compaction_revision, - state.context_usage.last_compacted_at, - )); - signature -} - -fn runtime_event_key(event: &AgentRuntimeEvent) -> String { - format!( - "{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", - event.agent_id, - event.task_id, - event.session_id, - event.run_id, - event.event_type, - event.action_id.as_deref().unwrap_or_default(), - event.status, - event.phase, - event.updated_at, - event.summary, - event.detail.as_deref().unwrap_or_default() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn runtime(status: &str, phase: &str, pending: u32) -> AgentRuntimeResult { - let state = serde_json::from_value::(serde_json::json!({ - "agentId": "code-prototype", - "runId": "run-test", - "status": status, - "phase": phase, - })) - .expect("deserialize runtime fixture"); - let mut task_queue = AgentRuntimeTaskQueueSummary::default(); - task_queue.pending = pending; - AgentRuntimeResult { - state, - accepted_run_id: None, - session_path: String::new(), - event_path: String::new(), - task_path: String::new(), - task_queue, - recent_events: Vec::new(), - recent_tasks: Vec::new(), - response_stream: None, - user_input_request: None, - } - } - - fn response_stream( - request_slot: &str, - response_revision: u64, - sequence: u64, - status: &str, - accumulated_text: &str, - ) -> AgentRuntimeResponseStream { - AgentRuntimeResponseStream { - schema_version: "game-creator-runtime-response-stream.v1".to_string(), - agent_id: "code-prototype".to_string(), - task_id: "code-prototype".to_string(), - session_id: "session-test".to_string(), - run_id: "run-test".to_string(), - request_kind: "final-reply".to_string(), - request_slot: request_slot.to_string(), - applied_steer_cursor: 0, - response_revision, - sequence, - status: status.to_string(), - accumulated_text: accumulated_text.to_string(), - finish_reason: None, - started_at: 100, - updated_at: 100 + sequence, - } - } - - fn runtime_with_response_stream(stream: AgentRuntimeResponseStream) -> AgentRuntimeResult { - let mut snapshot = runtime("running", "response", 0); - snapshot.state.session_id = stream.session_id.clone(); - snapshot.response_stream = Some(stream); - snapshot - } - - #[test] - fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { - for run_profile in [ - AGENT_RUNTIME_RUN_PROFILE_STANDARD, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, - ] { - assert_eq!( - resolve_swarm_new_run_launch( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_profile, - ) - .expect("resolve supervisor launch"), - SwarmNewRunLaunch::ProjectSupervisor { - source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - run_profile, - } - ); - } - } - - #[test] - fn explicit_parent_debug_keeps_standard_profile_only() { - assert_eq!( - resolve_swarm_new_run_launch("code-prototype", AGENT_RUNTIME_RUN_PROFILE_STANDARD,) - .expect("resolve explicit parent debug launch"), - SwarmNewRunLaunch::ExplicitParentDebug, - ); - assert!(resolve_swarm_new_run_launch( - "code-prototype", - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, - ) - .expect_err("autonomous profile must stay on the supervisor root run") - .contains(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)); - assert!(resolve_swarm_new_run_launch( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "unsupported", - ) - .is_err()); - } - - #[test] - fn same_run_steer_preserves_bound_autonomous_profile() { - let root = std::env::temp_dir().join(format!( - "swarm-cli-profile-steer-{}-{}", - std::process::id(), - unix_millis() - )); - init_local_game_project_at(&root, "project-swarm-profile", "Swarm Profile Steer") - .expect("initialize profile steer project"); - let run_id = "swarm-profile-steer-run"; - let binding = bind_game_creator_agent_runtime_run_profile_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), - None, - ) - .expect("bind autonomous supervisor profile"); - let state = start_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "生成一版可试玩项目", - run_id, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - "准备自主构建", - vec!["实现并验证最小可玩闭环".to_string()], - ) - .expect("start autonomous supervisor runtime"); - - let steered = steer_game_creator_agent_runtime_task_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.session_id, - run_id, - "swarm-profile-steer-1", - "保持当前目标并补充触屏操作", - "swarm-cli", - ) - .expect("steer autonomous supervisor runtime"); - - assert_eq!(steered.runtime.state.run_id, run_id); - assert_eq!( - steered.runtime.state.run_profile, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - ); - assert_eq!( - steered.runtime.state.run_profile_binding_fingerprint, - binding.binding_fingerprint - ); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn parses_chat_commands_without_stealing_normal_messages() { - assert_eq!(parse_swarm_chat_input(" "), None); - assert_eq!( - parse_swarm_chat_input("/agents"), - Some(SwarmChatInput::Agents) - ); - assert_eq!(parse_swarm_chat_input("/exit"), Some(SwarmChatInput::Quit)); - assert_eq!( - parse_swarm_chat_input("/status"), - Some(SwarmChatInput::Status) - ); - assert_eq!( - parse_swarm_chat_input("/compact"), - Some(SwarmChatInput::Compact) - ); - assert_eq!(parse_swarm_chat_input("/mcp"), Some(SwarmChatInput::Mcp)); - assert_eq!( - parse_swarm_chat_input("让策划和程序并行检查玩法"), - Some(SwarmChatInput::Message( - "让策划和程序并行检查玩法".to_string() - )) - ); - } - - #[test] - fn parses_goal_commands_and_keeps_goal_namespace_out_of_messages() { - assert_eq!( - parse_swarm_chat_input("/goal"), - Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)) - ); - assert_eq!( - parse_swarm_chat_input("/goal status"), - Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)) - ); - assert_eq!( - parse_swarm_chat_input("/goal 完成可玩的战斗循环"), - Some(SwarmChatInput::Goal(SwarmGoalCommand::Start( - "完成可玩的战斗循环".to_string() - ))) - ); - assert_eq!( - parse_swarm_chat_input("/goal edit 增加键盘与触屏验收"), - Some(SwarmChatInput::Goal(SwarmGoalCommand::Edit( - "增加键盘与触屏验收".to_string() - ))) - ); - assert_eq!( - parse_swarm_chat_input("/goal pause"), - Some(SwarmChatInput::Goal(SwarmGoalCommand::Pause)) - ); - assert_eq!( - parse_swarm_chat_input("/goal resume"), - Some(SwarmChatInput::Goal(SwarmGoalCommand::Resume)) - ); - assert_eq!( - parse_swarm_chat_input("/goal clear"), - Some(SwarmChatInput::Goal(SwarmGoalCommand::Clear)) - ); - - for invalid in [ - "/goal-status", - "/goal/status", - "/goal edit", - "/goal pause now", - ] { - assert!(matches!( - parse_swarm_chat_input(invalid), - Some(SwarmChatInput::InvalidGoal(_)) - )); - assert!(!matches!( - parse_swarm_chat_input(invalid), - Some(SwarmChatInput::Message(_)) - )); - } - } - - #[test] - fn swarm_help_lists_the_complete_goal_control_surface() { - let mut output = Vec::new(); - print_swarm_chat_help(&mut output).expect("print swarm help"); - let output = String::from_utf8(output).expect("help output is utf-8"); - - for command in [ - "/status", - "/compact", - "/mcp", - "/goal <目标>", - "/goal status", - "/goal edit <目标>", - "/goal pause", - "/goal resume", - "/goal clear", - ] { - assert!(output.contains(command), "missing help command: {command}"); - } - } - - #[test] - fn swarm_mcp_catalog_is_bounded_and_omits_server_instructions() { - let instruction = "PRIVATE_MCP_SERVER_INSTRUCTIONS"; - let catalog = GameCreatorMcpCatalog { - fingerprint: "a".repeat(64), - servers: vec![GameCreatorMcpServerStatus { - server_id: "fixture".to_string(), - enabled: true, - required: false, - transport: "stdio".to_string(), - connected: true, - server_name: Some("fixture-server".to_string()), - server_version: Some("1.0.0".to_string()), - instructions: instruction.to_string(), - instructions_chars: instruction.chars().count(), - tool_count: 1, - error: None, - }], - tools: vec![GameCreatorMcpCatalogTool { - server_id: "fixture".to_string(), - name: "lookup".to_string(), - title: Some("Fixture lookup".to_string()), - description: format!("{} DESCRIPTION_TAIL_SENTINEL", "D".repeat(240)), - input_schema: serde_json::json!({ - "type": "object", - "description": format!("{} SCHEMA_TAIL_SENTINEL", "S".repeat(800)), - }), - output_schema: None, - read_only_hint: true, - destructive_hint: false, - open_world_hint: false, - configured_approval_mode: "auto".to_string(), - effective_approval_mode: "auto".to_string(), - fingerprint: "b".repeat(64), - }], - }; - let mut output = Vec::new(); - print_swarm_mcp_catalog(&catalog, &mut output).expect("print MCP catalog"); - let output = String::from_utf8(output).expect("MCP output is utf-8"); - - assert!(output.contains("catalog=aaaaaaaaaaaa servers=1 tools=1")); - assert!(output.contains("server=fixture transport=stdio")); - assert!(output.contains("tool=fixture/lookup approval=auto readOnly=true")); - assert!(!output.contains(instruction)); - assert!(!output.contains("DESCRIPTION_TAIL_SENTINEL")); - assert!(!output.contains("SCHEMA_TAIL_SENTINEL")); - assert!(output.len() < 1_200); - } - - #[test] - fn goal_status_prints_identity_outcome_and_completion_standard() { - let goal = AgentGoalRecord { - schema_version: AGENT_GOAL_SCHEMA_VERSION.to_string(), - project_id: "project-1".to_string(), - goal_id: "goal-1".to_string(), - agent_id: "project-supervisor".to_string(), - session_id: "session-1".to_string(), - run_id: "run-1".to_string(), - revision: 3, - status: AGENT_GOAL_STATUS_ACTIVE.to_string(), - outcome: "完成首个可玩版本".to_string(), - constraints: vec!["不新增平行 Runtime".to_string()], - verification: vec!["键盘与触屏均可完成一局".to_string()], - completion_evidence: Vec::new(), - response_fingerprint: None, - created_at: 1, - pause_requested_at: None, - paused_at: None, - completed_at: None, - cleared_at: None, - error: None, - updated_at: 2, - }; - let mut output = Vec::new(); - print_swarm_goal_status("session-1", Some(&goal), &mut output).expect("print goal status"); - let output = String::from_utf8(output).expect("goal output is utf-8"); - - assert!(output.contains("goal=goal-1 run=run-1 revision=3 status=active")); - assert!(output.contains("[Goal 目标] 完成首个可玩版本")); - assert!(output.contains("[Goal 约束] 不新增平行 Runtime")); - assert!(output.contains("[Goal 完成标准] 键盘与触屏均可完成一局")); - } - - #[test] - fn swarm_stays_busy_for_active_queue_and_reconciliation() { - assert!(runtimes_are_busy(&[runtime("running", "planning", 0)])); - assert!(runtimes_are_busy(&[runtime("idle", "completed", 1)])); - assert!(runtimes_are_busy(&[runtime( - "failed", - "needs-reconciliation", - 0 - )])); - assert!(!runtimes_are_busy(&[runtime("idle", "completed", 0)])); - assert!(!runtimes_are_busy(&[runtime("failed", "failed", 0)])); - } - - #[test] - fn active_turn_eof_closes_input_once_without_requesting_quit() { - let mut input_closed = false; - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - - mark_swarm_turn_input_closed(&mut input_closed, &mut observer, &mut output) - .expect("close active turn input"); - mark_swarm_turn_input_closed(&mut input_closed, &mut observer, &mut output) - .expect("repeat closed input is idempotent"); - - assert!(input_closed); - let output = String::from_utf8(output).expect("input close output is utf-8"); - assert_eq!(output.matches("[输入已关闭]").count(), 1); - assert!(output.contains("继续运行,等待可信终态")); - assert!(!output.contains("已退出 Agent Swarm Chat")); - } - - #[test] - fn active_turn_eof_keeps_observing_until_parent_completes() { - let root = std::env::temp_dir().join(format!( - "swarm-cli-eof-active-{}-{}", - std::process::id(), - unix_millis() - )); - init_local_game_project_at(&root, "project-swarm-eof", "Swarm EOF active turn") - .expect("initialize EOF project"); - for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - for role in group.roles { - let idle = default_game_creator_agent_runtime_state(role.task_id, "run-eof-idle"); - write_game_creator_agent_runtime_state(&root, &idle) - .expect("persist valid idle specialist state"); - } - } - let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; - let before = append_local_conversation_message_at( - &root, - Some(parent_agent_id), - LocalConversationMessage { - role: "user".to_string(), - content: "继续完成当前项目".to_string(), - agent_id: Some(parent_agent_id.to_string()), - }, - ) - .expect("append turn user message"); - let session_id = before.session_id.clone().expect("active parent session"); - let mut parent = runtime("running", "planning", 0).state; - parent.agent_id = parent_agent_id.to_string(); - parent.task_id = parent_agent_id.to_string(); - parent.session_id = session_id.clone(); - parent.run_id = "run-eof-active".to_string(); - parent.source = "agent-background-task".to_string(); - parent.current_task = "继续完成当前项目".to_string(); - write_game_creator_agent_runtime_state(&root, &parent).expect("persist active parent"); - let conversation_baseline = - new_swarm_turn_conversation_baseline(before.messages.len(), &parent.run_id); - - let completion_root = root.clone(); - let completion_session_id = session_id.clone(); - let completion = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(15)); - append_local_conversation_message_for_session_at( - &completion_root, - Some(parent_agent_id), - Some(&completion_session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: "已完成可信终态".to_string(), - agent_id: Some(parent_agent_id.to_string()), - }, - ) - .expect("append terminal assistant message"); - parent.status = "idle".to_string(); - parent.phase = "completed".to_string(); - parent.updated_at = unix_timestamp(); - write_game_creator_agent_runtime_state(&completion_root, &parent) - .expect("persist completed parent"); - }); - let (tx, rx) = mpsc::channel(); - tx.send(SwarmInputEvent::Eof).expect("send active turn EOF"); - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - - let outcome = wait_for_swarm_turn( - &root, - parent_agent_id, - &session_id, - conversation_baseline, - &rx, - &mut output, - &mut observer, - Duration::from_millis(2), - Duration::from_millis(8), - ) - .expect("observe active turn after EOF"); - completion.join().expect("join completion writer"); - - let output = String::from_utf8(output).expect("EOF turn output is utf-8"); - let runtime_diagnostics = read_game_creator_agent_runtimes_at(&root) - .expect("read terminal runtime diagnostics") - .into_iter() - .filter(|runtime| runtime.state.phase == "needs-reconciliation") - .map(|runtime| { - format!( - "{}:{}", - runtime.state.agent_id, - runtime.state.error.unwrap_or_default() - ) - }) - .collect::>(); - assert!( - matches!(outcome, SwarmTurnOutcome::Settled(_)), - "unexpected outcome: {outcome:?}; diagnostics={runtime_diagnostics:?}; output={output}" - ); - assert!(output.contains("[输入已关闭]")); - assert!(output.contains("已完成可信终态")); - assert!(!output.contains("已退出 Agent Swarm Chat")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn recovered_prebaseline_assistant_counts_once_without_duplicate_output() { - let root = std::env::temp_dir().join(format!( - "swarm-cli-recovered-assistant-{}-{}", - std::process::id(), - unix_millis() - )); - init_local_game_project_at( - &root, - "project-swarm-recovered-assistant", - "Swarm recovered assistant", - ) - .expect("initialize recovered assistant project"); - let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; - let conversation = append_local_conversation_message_at( - &root, - Some(parent_agent_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: "已在恢复阶段持久化".to_string(), - agent_id: Some(parent_agent_id.to_string()), - }, - ) - .expect("append recovered assistant"); - let session_id = conversation.session_id.expect("active parent session"); - let mut baseline = - new_swarm_turn_conversation_baseline(conversation.messages.len(), "run-recovered"); - baseline.recovered_assistant = Some(SwarmRecoveredAssistant { - run_id: "run-recovered".to_string(), - finalization_id: "finalization-recovered".to_string(), - message_id: "message-recovered".to_string(), - content: "已在恢复阶段持久化".to_string(), - }); - - let snapshot = - read_turn_conversation_snapshot(&root, parent_agent_id, &session_id, &baseline) - .expect("read recovered conversation snapshot"); - assert_eq!(snapshot.metrics.new_assistant_message_count, 1); - assert_eq!( - snapshot.metrics.final_reply_chars, - "已在恢复阶段持久化".chars().count() - ); - assert_eq!(snapshot.final_reply.as_deref(), Some("已在恢复阶段持久化")); - assert!(snapshot.recovered_before_observation); - - let mut output = Vec::new(); - let mut observer = SwarmRuntimeObserver::default(); - let metrics = print_new_parent_reply( - &root, - parent_agent_id, - &session_id, - &baseline, - &mut output, - &mut observer, - ) - .expect("print recovered parent reply"); - assert_eq!(metrics, snapshot.metrics); - let output = String::from_utf8(output).expect("recovered output is utf-8"); - assert!(output.contains("父 Agent 回复已在恢复前持久化")); - assert!(!output.contains("已在恢复阶段持久化")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn recovered_assistant_cannot_overlap_a_new_terminal_reply() { - let root = std::env::temp_dir().join(format!( - "swarm-cli-recovered-overlap-{}-{}", - std::process::id(), - unix_millis() - )); - init_local_game_project_at( - &root, - "project-swarm-recovered-overlap", - "Swarm recovered overlap", - ) - .expect("initialize recovered overlap project"); - let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; - let before = - read_local_conversation_for_session_at(root.as_path(), Some(parent_agent_id), None) - .expect("read initial conversation"); - let session_id = before.session_id.expect("active parent session"); - append_local_conversation_message_for_session_at( - &root, - Some(parent_agent_id), - Some(&session_id), - LocalConversationMessage { - role: "assistant".to_string(), - content: "baseline 后的新回复".to_string(), - agent_id: Some(parent_agent_id.to_string()), - }, - ) - .expect("append new terminal reply"); - let mut baseline = - new_swarm_turn_conversation_baseline(before.messages.len(), "run-overlap"); - baseline.recovered_assistant = Some(SwarmRecoveredAssistant { - run_id: "run-overlap".to_string(), - finalization_id: "finalization-overlap".to_string(), - message_id: "message-overlap".to_string(), - content: "恢复回复".to_string(), - }); - - let error = read_turn_conversation_snapshot(&root, parent_agent_id, &session_id, &baseline) - .expect_err("recovered and new assistant replies must not be double counted"); - assert!(error.contains("与 baseline 后的新回复重叠")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn confirmation_prompt_propagates_eof_as_closed_input() { - let (tx, rx) = mpsc::channel(); - tx.send(SwarmInputEvent::Eof).expect("send eof"); - let mut output = Vec::new(); - - let decision = prompt_swarm_decision( - Path::new("."), - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &rx, - &mut output, - "confirm> ", - ) - .expect("EOF is a turn input state, not an error"); - - assert!(matches!(decision, SwarmPromptDecision::InputClosed)); - } - - #[test] - fn terminal_classifier_requires_completed_parent_unique_reply_and_clear_contract() { - let mut parent = runtime("idle", "completed", 0); - parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); - parent.state.session_id = "session-terminal".to_string(); - parent.state.run_id = "run-terminal".to_string(); - let unique_reply = SwarmTurnConversationMetrics { - new_assistant_message_count: 1, - final_reply_chars: 12, - }; - - assert_eq!( - classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 0, 0), - SwarmTurnTerminalClassification::Settled - ); - assert_eq!( - classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 1, 0), - SwarmTurnTerminalClassification::Incomplete - ); - assert_eq!( - classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 0, 1), - SwarmTurnTerminalClassification::Incomplete - ); - assert_eq!( - classify_swarm_turn_terminal( - Some(&parent), - SwarmTurnConversationMetrics::default(), - 0, - 0, - 0, - ), - SwarmTurnTerminalClassification::Incomplete - ); - assert_eq!( - classify_swarm_turn_terminal( - Some(&parent), - SwarmTurnConversationMetrics { - new_assistant_message_count: 2, - final_reply_chars: 12, - }, - 0, - 0, - 0, - ), - SwarmTurnTerminalClassification::Incomplete - ); - assert_eq!( - classify_swarm_turn_terminal(None, unique_reply, 0, 0, 0), - SwarmTurnTerminalClassification::Incomplete - ); - } - - #[test] - fn terminal_classifier_fails_parent_failure_cancel_and_budget_exhaustion() { - let metrics = SwarmTurnConversationMetrics { - new_assistant_message_count: 1, - final_reply_chars: 8, - }; - for (status, phase) in [ - ("failed", "failed"), - ("cancelled", "cancelled"), - ("failed", "budget-exhausted"), - ] { - let parent = runtime(status, phase, 0); - assert_eq!( - classify_swarm_turn_terminal(Some(&parent), metrics, 0, 0, 0), - SwarmTurnTerminalClassification::Failed, - "parent {status}/{phase} must fail closed" - ); - } - let completed = runtime("idle", "completed", 0); - assert_eq!( - classify_swarm_turn_terminal(Some(&completed), metrics, 1, 0, 0), - SwarmTurnTerminalClassification::Failed - ); - } - - #[test] - fn pending_interactions_never_form_a_settled_snapshot() { - let mut parent = runtime("waiting-for-user-input", "waiting-for-user-input", 0); - parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); - let mut child = runtime("waiting-for-user-input", "waiting-for-user-input", 0); - child.state.agent_id = "code-prototype".to_string(); - let mut confirmation = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); - confirmation.state.agent_id = "quality-review".to_string(); - - assert!(swarm_unhandled_interaction_reasons( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &[parent.clone()], - false, - ) - .is_empty()); - let child_reasons = swarm_unhandled_interaction_reasons( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &[child], - false, - ); - assert_eq!(child_reasons, vec!["pending-user-input:code-prototype"]); - let closed_reasons = swarm_unhandled_interaction_reasons( - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &[parent, confirmation], - true, - ); - assert!(closed_reasons - .iter() - .any(|reason| reason == "pending-user-input:project-supervisor")); - assert!(closed_reasons - .iter() - .any(|reason| reason == "pending-confirmation:quality-review")); - } - - #[test] - fn original_specialist_failure_is_recoverable_but_repair_failure_closes() { - let mut parent = runtime("running", "waiting-for-delegate-receipts", 0); - parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); - parent.state.session_id = "session-parent".to_string(); - parent.state.run_id = "run-parent".to_string(); - let mut child = runtime("failed", "failed", 0); - child.state.agent_id = "code-prototype".to_string(); - child.state.session_id = "session-child".to_string(); - child.state.run_id = "run-child".to_string(); - child.state.source = "agent-delegate".to_string(); - child.state.parent_agent_id = Some(parent.state.agent_id.clone()); - child.state.parent_run_id = Some(parent.state.run_id.clone()); - child.state.delegation_id = Some("delivery-original".to_string()); - let acceptance = vec!["交付可运行原型".to_string()]; - let original = new_static_delegate_delivery_with_contract( - &parent.state.agent_id, - &parent.state.session_id, - &parent.state.run_id, - "action-original", - "delivery-original", - &child.state.agent_id, - &child.state.session_id, - &child.state.run_id, - &acceptance, - &[], - None, - ); - - assert_eq!( - classify_failed_specialist(&parent, &child, Some(&original), false), - SwarmSpecialistFailureDisposition::Recoverable - ); - let mut completed_parent = parent.clone(); - completed_parent.state.status = "idle".to_string(); - completed_parent.state.phase = "completed".to_string(); - assert_eq!( - classify_failed_specialist(&completed_parent, &child, Some(&original), false), - SwarmSpecialistFailureDisposition::Incomplete - ); - - child.state.run_id = "run-repair".to_string(); - child.state.delegation_id = Some("delivery-repair".to_string()); - let repair = new_static_delegate_delivery_with_contract( - &parent.state.agent_id, - &parent.state.session_id, - &parent.state.run_id, - "action-repair", - "delivery-repair", - &child.state.agent_id, - &child.state.session_id, - &child.state.run_id, - &acceptance, - &[], - Some("delivery-original"), - ); - assert_eq!( - classify_failed_specialist(&parent, &child, Some(&repair), false), - SwarmSpecialistFailureDisposition::Failed - ); - } - - #[test] - fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { - let root = std::env::temp_dir().join(format!( - "swarm-cli-repair-scan-{}-{}", - std::process::id(), - unix_millis() - )); - init_local_game_project_at(&root, "project-swarm-repair", "Swarm repair scan") - .expect("initialize repair scan project"); - let mut parent = runtime("running", "waiting-for-delegate-receipts", 0); - parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); - parent.state.session_id = "session-parent".to_string(); - parent.state.run_id = "run-parent".to_string(); - let mut child = runtime("failed", "failed", 0); - child.state.agent_id = "code-prototype".to_string(); - child.state.session_id = "session-child".to_string(); - child.state.run_id = "run-child".to_string(); - child.state.source = "agent-delegate".to_string(); - child.state.parent_agent_id = Some(parent.state.agent_id.clone()); - child.state.parent_run_id = Some(parent.state.run_id.clone()); - child.state.delegation_id = Some("delivery-original".to_string()); - let acceptance = vec!["交付可运行原型".to_string()]; - let original = new_static_delegate_delivery_with_contract( - &parent.state.agent_id, - &parent.state.session_id, - &parent.state.run_id, - "action-original", - "delivery-original", - &child.state.agent_id, - &child.state.session_id, - &child.state.run_id, - &acceptance, - &[], - None, - ); - create_or_read_static_delegate_delivery_at(&root, &original) - .expect("persist original delivery"); - - let original_scan = scan_swarm_terminal_failures_at( - &root, - &parent.state.agent_id, - &parent.state.session_id, - &[parent.clone(), child.clone()], - ); - assert!(original_scan.failed_agents.is_empty()); - assert!(original_scan.incomplete_reasons.is_empty()); - assert!(original_scan.reconciliation_agents.is_empty()); - - child.state.run_id = "run-repair".to_string(); - child.state.delegation_id = Some("delivery-repair".to_string()); - let repair = new_static_delegate_delivery_with_contract( - &parent.state.agent_id, - &parent.state.session_id, - &parent.state.run_id, - "action-repair", - "delivery-repair", - &child.state.agent_id, - &child.state.session_id, - &child.state.run_id, - &acceptance, - &[], - Some("delivery-original"), - ); - create_or_read_static_delegate_delivery_at(&root, &repair) - .expect("persist repair delivery"); - let repair_scan = scan_swarm_terminal_failures_at( - &root, - &parent.state.agent_id, - &parent.state.session_id, - &[parent.clone(), child], - ); - assert_eq!(repair_scan.failed_agents, vec!["code-prototype:failed"]); - assert!(repair_scan.incomplete_reasons.is_empty()); - assert!(repair_scan.reconciliation_agents.is_empty()); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn missing_confirmation_sidecar_is_reported_as_reconciliation() { - let broken = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); - assert_eq!( - swarm_reconciliation_agents(&[broken]), - vec!["code-prototype".to_string()] - ); - } - - #[test] - fn turn_report_counts_runtime_and_conversation_snapshots() { - let mut parent = runtime("running", "response", 2); - parent.state.agent_id = "project-supervisor".to_string(); - parent.state.session_id = "session-report".to_string(); - parent.state.run_id = "run-parent".to_string(); - parent.task_queue.running = 1; - parent.task_queue.waiting_for_confirmation = 2; - - let mut child = runtime("idle", "completed", 3); - child.state.agent_id = "child-code".to_string(); - child.task_queue.waiting_for_user_input = 1; - - let mut idle = runtime("idle", "completed", 0); - idle.state.agent_id = "design-review".to_string(); - let runtimes = vec![parent, child, idle]; - let (conversation_metrics, final_reply) = summarize_new_assistant_messages([ - ("user", "请继续"), - ("assistant", "阶段回复"), - ("tool", "PRIVATE_OBSERVATION"), - ("assistant", "最终🙂"), - ]); - assert_eq!(final_reply, Some("最终🙂")); - - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::NeedsReconciliation, - "project-supervisor", - "session-report", - &runtimes, - conversation_metrics, - 1, - ); - - assert_eq!(report.schema_version, SWARM_TURN_REPORT_SCHEMA_VERSION); - assert_eq!(report.outcome, SwarmTurnReportOutcome::NeedsReconciliation); - assert_eq!(report.parent_agent_id, "project-supervisor"); - assert_eq!(report.session_id, "session-report"); - assert_eq!(report.parent_run_id.as_deref(), Some("run-parent")); - assert_eq!(report.runtime_count, 3); - assert_eq!(report.busy_runtime_count, 2); - assert_eq!(report.pending_task_count, 5); - assert_eq!(report.running_task_count, 1); - assert_eq!(report.waiting_for_confirmation_count, 2); - assert_eq!(report.waiting_for_user_input_count, 1); - assert_eq!(report.new_assistant_message_count, 2); - assert_eq!(report.final_reply_chars, "最终🙂".chars().count()); - assert_eq!(report.reconciliation_agent_count, 1); - } - - #[test] - fn turn_report_json_is_single_line_and_omits_sensitive_bodies_and_paths() { - let sensitive_reply = concat!( - "PRIVATE_REPLY_BODY\n", - "/private/project/root ", - "prompt=DO_NOT_LEAK observation=DO_NOT_LEAK CREDENTIAL_SENTINEL" - ); - let (conversation_metrics, _) = - summarize_new_assistant_messages([("assistant", sensitive_reply)]); - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::Settled, - "project-supervisor", - "session-safe", - &[], - conversation_metrics, - 0, - ); - let json = serde_json::to_string(&report).expect("serialize turn report"); - let value = serde_json::from_str::(&json).expect("parse turn report"); - let object = value.as_object().expect("turn report is an object"); - - assert_eq!(json.lines().count(), 1); - assert_eq!(object.len(), 14); - for key in [ - "schemaVersion", - "outcome", - "parentAgentId", - "sessionId", - "parentRunId", - "runtimeCount", - "busyRuntimeCount", - "pendingTaskCount", - "runningTaskCount", - "waitingForConfirmationCount", - "waitingForUserInputCount", - "newAssistantMessageCount", - "finalReplyChars", - "reconciliationAgentCount", - ] { - assert!(object.contains_key(key), "turn report omitted {key}"); - } - assert_eq!( - value["schemaVersion"], - serde_json::json!(SWARM_TURN_REPORT_SCHEMA_VERSION) - ); - assert_eq!(value["outcome"], serde_json::json!("settled")); - assert_eq!(value["parentRunId"], serde_json::Value::Null); - assert_eq!(value["newAssistantMessageCount"], serde_json::json!(1)); - assert_eq!( - value["finalReplyChars"], - serde_json::json!(sensitive_reply.chars().count()) - ); - for forbidden in [ - "PRIVATE_REPLY_BODY", - "/private/project/root", - "DO_NOT_LEAK", - "CREDENTIAL_SENTINEL", - ] { - assert!(!json.contains(forbidden), "report leaked {forbidden}"); - } - } - - #[test] - fn turn_outcome_prints_all_terminal_reports_but_not_quit() { - let metrics = SwarmTurnConversationMetrics { - new_assistant_message_count: 1, - final_reply_chars: 4, - }; - let settled_report = build_swarm_turn_report( - SwarmTurnReportOutcome::Settled, - "project-supervisor", - "session-settled", - &[], - metrics, - 0, - ); - let mut settled_output = Vec::new(); - print_turn_outcome( - SwarmTurnOutcome::Settled(settled_report), - &mut settled_output, - ) - .expect("print settled report"); - let settled_output = String::from_utf8(settled_output).expect("settled output is utf-8"); - assert_eq!(settled_output.lines().count(), 1); - assert!(settled_output.starts_with(SWARM_TURN_REPORT_PREFIX)); - assert!(settled_output.contains("\"outcome\":\"settled\"")); - - let failed_report = build_swarm_turn_report( - SwarmTurnReportOutcome::Failed, - "project-supervisor", - "session-failed", - &[], - metrics, - 0, - ); - let mut failed_output = Vec::new(); - print_turn_outcome( - SwarmTurnOutcome::Failed { - agent_ids: vec!["project-supervisor:budget-exhausted".to_string()], - report: failed_report, - }, - &mut failed_output, - ) - .expect("print failed report"); - let failed_output = String::from_utf8(failed_output).expect("failed output is utf-8"); - assert!(failed_output.starts_with("[已失败]")); - assert!(failed_output.contains("\"outcome\":\"failed\"")); - - let incomplete_report = build_swarm_turn_report( - SwarmTurnReportOutcome::Incomplete, - "project-supervisor", - "session-incomplete", - &[], - metrics, - 0, - ); - let mut incomplete_output = Vec::new(); - print_turn_outcome( - SwarmTurnOutcome::Incomplete { - reasons: vec!["assistant-count=0".to_string()], - report: incomplete_report, - }, - &mut incomplete_output, - ) - .expect("print incomplete report"); - let incomplete_output = - String::from_utf8(incomplete_output).expect("incomplete output is utf-8"); - assert!(incomplete_output.starts_with("[未完成]")); - assert!(incomplete_output.contains("\"outcome\":\"incomplete\"")); - - let reconciliation_report = build_swarm_turn_report( - SwarmTurnReportOutcome::NeedsReconciliation, - "project-supervisor", - "session-reconciliation", - &[], - metrics, - 2, - ); - let mut reconciliation_output = Vec::new(); - print_turn_outcome( - SwarmTurnOutcome::NeedsReconciliation { - agent_ids: vec!["code-prototype".to_string(), "external-runner".to_string()], - report: reconciliation_report, - }, - &mut reconciliation_output, - ) - .expect("print reconciliation report"); - let reconciliation_output = - String::from_utf8(reconciliation_output).expect("reconciliation output is utf-8"); - let lines = reconciliation_output.lines().collect::>(); - assert_eq!(lines.len(), 2); - assert_eq!( - lines[0], - "[已阻断] 以下 Agent 需要人工 reconciliation:code-prototype, external-runner" - ); - assert!(lines[1].starts_with(SWARM_TURN_REPORT_PREFIX)); - assert!(lines[1].contains("\"outcome\":\"needs-reconciliation\"")); - assert!(lines[1].contains("\"reconciliationAgentCount\":2")); - - let mut quit_output = Vec::new(); - print_turn_outcome(SwarmTurnOutcome::Quit, &mut quit_output).expect("ignore quit"); - assert!(quit_output.is_empty()); - } - - #[test] - fn blank_agent_snapshots_do_not_reset_the_settle_window() { - let mut blank = runtime("idle", "idle", 0); - blank.state.run_id.clear(); - blank.state.updated_at = 100; - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - assert!(!observer - .print_changes(&[blank.clone()], &mut output) - .expect("observe first blank snapshot")); - blank.state.updated_at = 101; - assert!(!observer - .print_changes(&[blank], &mut output) - .expect("observe refreshed blank snapshot")); - assert!(output.is_empty()); - } - - #[test] - fn runtime_plan_revision_and_current_step_change_state_signature() { - let mut snapshot = runtime("running", "planning", 0); - snapshot.state.updated_at = 100; - snapshot.task_queue.updated_at = 100; - snapshot.state.plan_revision = 1; - snapshot.state.plan_steps = vec![ - AgentRuntimePlanStep { - index: 0, - title: "读取现有 CLI".to_string(), - status: "in_progress".to_string(), - detail: None, - updated_at: 100, - }, - AgentRuntimePlanStep { - index: 1, - title: "补充计划展示".to_string(), - status: "pending".to_string(), - detail: None, - updated_at: 100, - }, - ]; - snapshot.state.active_plan_step_index = Some(0); - - let initial = runtime_state_signature(&snapshot.state, &snapshot.task_queue); - snapshot.state.plan_revision = 2; - let revised = runtime_state_signature(&snapshot.state, &snapshot.task_queue); - assert_ne!(initial, revised); - - snapshot.state.plan_steps[0].title = "核对现有 CLI".to_string(); - let current_step_changed = runtime_state_signature(&snapshot.state, &snapshot.task_queue); - assert_ne!(revised, current_step_changed); - - snapshot.state.plan_steps[0].status = "completed".to_string(); - snapshot.state.plan_steps[1].status = "in_progress".to_string(); - snapshot.state.active_plan_step_index = Some(1); - let advanced = runtime_state_signature(&snapshot.state, &snapshot.task_queue); - assert_ne!(current_step_changed, advanced); - } - - #[test] - fn runtime_plan_output_is_bounded_and_omits_private_observations() { - let mut snapshot = runtime("running", "planning", 0); - snapshot.state.plan_revision = 7; - snapshot.state.plan_explanation = "已完成读取,进入验证".to_string(); - snapshot.state.current_action = "展示持久计划".to_string(); - snapshot.state.waiting_on = "开发者确认".to_string(); - snapshot.state.next_step = "运行 focused cargo test".to_string(); - snapshot.state.observations = vec![ - "PRIVATE_OBSERVATION_SENTINEL".to_string(), - "PRIVATE_DETAIL_SENTINEL".to_string(), - ]; - snapshot.state.plan_steps = (0..10) - .map(|index| AgentRuntimePlanStep { - index, - title: format!("计划步骤 {}", index + 1), - status: match index { - 0 | 1 => "completed", - 2 => "in_progress", - _ => "pending", - } - .to_string(), - detail: Some(format!("PRIVATE_STEP_DETAIL_{index}")), - updated_at: 100, - }) - .collect(); - snapshot.state.active_plan_step_index = Some(2); - - let mut output = Vec::new(); - print_runtime_state(&snapshot.state, &snapshot.task_queue, &mut output) - .expect("print runtime plan progress"); - let output = String::from_utf8(output).expect("runtime output is utf-8"); - - assert!(output.contains( - "[计划] revision=7 completed=2/10 current=#3 [in_progress] 计划步骤 3 | waiting=开发者确认 | next=运行 focused cargo test" - )); - assert!(output.contains("[计划说明] 已完成读取,进入验证")); - assert_eq!(output.matches("[计划步骤]").count(), 8); - assert!(output.contains("[计划步骤] #8 [pending] 计划步骤 8")); - assert!(output.contains("另有 2 条步骤未显示")); - assert!(!output.contains("计划步骤 9")); - assert!(!output.contains("PRIVATE_OBSERVATION_SENTINEL")); - assert!(!output.contains("PRIVATE_DETAIL_SENTINEL")); - assert!(!output.contains("PRIVATE_STEP_DETAIL")); - } - - #[test] - fn response_stream_prints_only_monotonic_utf8_suffixes() { - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - let mut snapshot = runtime_with_response_stream(response_stream( - "slot-1", - 7, - 0, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - "", - )); - - assert!(observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe empty response stream")); - snapshot.response_stream = Some(response_stream( - "slot-1", - 7, - 1, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - "你", - )); - assert!(observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe first utf-8 suffix")); - snapshot.response_stream = Some(response_stream( - "slot-1", - 7, - 2, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, - "你好🙂", - )); - assert!(observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe second utf-8 suffix")); - assert!(!observer - .print_changes(&[snapshot], &mut output) - .expect("ignore duplicate snapshot")); - observer - .close_response_line(&mut output) - .expect("close response line"); - - let output = String::from_utf8(output).expect("stream output is utf-8"); - assert!(output.contains("Agent[code-prototype]> 你好🙂")); - assert_eq!(output.matches("Agent[code-prototype]>").count(), 1); - assert!(!output.contains("你你好")); - } - - #[test] - fn response_stream_resets_for_non_prefix_and_new_request_slot() { - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - let mut snapshot = runtime_with_response_stream(response_stream( - "slot-1", - 9, - 1, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - "旧稿", - )); - observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe initial stream"); - - snapshot.response_stream = Some(response_stream( - "slot-1", - 9, - 2, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, - "修正版", - )); - observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe non-prefix correction"); - snapshot.response_stream = Some(response_stream( - "slot-2", - 9, - 1, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, - "最终版", - )); - observer - .print_changes(&[snapshot], &mut output) - .expect("observe new request slot"); - observer - .close_response_line(&mut output) - .expect("close response line"); - - let output = String::from_utf8(output).expect("stream output is utf-8"); - assert!(output.contains("reason=non-prefix-correction")); - assert!(output.contains("reason=new-request-slot")); - assert_eq!(output.matches("旧稿").count(), 1); - assert_eq!(output.matches("修正版").count(), 1); - assert_eq!(output.matches("最终版").count(), 1); - } - - #[test] - fn response_stream_resets_sequence_for_same_run_steer_cursor() { - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - let mut initial = response_stream( - "slot-1", - 9, - 4, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - "纠偏前回复", - ); - initial.applied_steer_cursor = 1; - observer - .print_changes(&[runtime_with_response_stream(initial)], &mut output) - .expect("observe pre-steer stream"); - - let mut steered = response_stream( - "slot-1", - 9, - 1, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, - "纠偏后回复", - ); - steered.applied_steer_cursor = 2; - observer - .print_changes(&[runtime_with_response_stream(steered)], &mut output) - .expect("observe same-run stream after steer"); - observer - .close_response_line(&mut output) - .expect("close steered response line"); - - let output = String::from_utf8(output).expect("steer output is utf-8"); - assert!(output.contains("reason=new-steer-cursor")); - assert!(!output.contains("reason=sequence-rollback")); - assert_eq!(output.matches("纠偏前回复").count(), 1); - assert_eq!(output.matches("纠偏后回复").count(), 1); - } - - #[test] - fn response_stream_reconnects_without_repeating_body_and_rejects_sequence_rollback() { - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - let mut snapshot = runtime_with_response_stream(response_stream( - "slot-1", - 11, - 3, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - "已输出", - )); - observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe initial stream"); - - snapshot.response_stream = None; - assert!(observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe disconnect")); - snapshot.response_stream = Some(response_stream( - "slot-1", - 11, - 3, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - "已输出", - )); - assert!(observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe reconnect")); - - snapshot.response_stream = Some(response_stream( - "slot-1", - 11, - 2, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, - "回退正文", - )); - assert!(observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("report sequence rollback")); - assert!(!observer - .print_changes(&[snapshot], &mut output) - .expect("deduplicate repeated rollback")); - - let cursor = observer - .response_streams - .get("code-prototype") - .expect("response cursor"); - assert_eq!(cursor.sequence, 3); - assert_eq!(cursor.accumulated_text, "已输出"); - assert_eq!(cursor.printed_accumulated_text.as_deref(), Some("已输出")); - - let recovered = runtime_with_response_stream(response_stream( - "slot-1", - 11, - 4, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, - "已输出继续", - )); - assert!(observer - .print_changes(&[recovered], &mut output) - .expect("resume from accepted high-water mark")); - observer - .close_response_line(&mut output) - .expect("close recovered response line"); - - let output = String::from_utf8(output).expect("stream output is utf-8"); - assert!(output.contains("reason=reconnect")); - assert!(output.contains("reason=sequence-rollback")); - assert_eq!(output.matches("已输出").count(), 1); - assert_eq!(output.matches("继续").count(), 1); - assert!(!output.contains("回退正文")); - } - - #[test] - fn settled_parent_reply_is_not_repeated_after_complete_stream() { - let mut observer = SwarmRuntimeObserver::default(); - let mut output = Vec::new(); - let mut snapshot = runtime_with_response_stream(response_stream( - "slot-1", - 13, - 4, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, - "权威最终回复", - )); - observer - .print_changes(&[snapshot.clone()], &mut output) - .expect("observe complete stream"); - snapshot.response_stream = Some(response_stream( - "slot-1", - 13, - 5, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED, - "权威最终回复", - )); - observer - .print_changes(&[snapshot], &mut output) - .expect("observe committed stream without printing body"); - observer - .close_response_line(&mut output) - .expect("close response line"); - print_settled_parent_reply( - "code-prototype", - "session-test", - Some("权威最终回复"), - &observer, - &mut output, - ) - .expect("settle streamed reply"); - let (conversation_metrics, _) = - summarize_new_assistant_messages([("assistant", "权威最终回复")]); - let report = build_swarm_turn_report( - SwarmTurnReportOutcome::Settled, - "code-prototype", - "session-test", - &[], - conversation_metrics, - 0, - ); - print_turn_outcome(SwarmTurnOutcome::Settled(report), &mut output) - .expect("print settled report after stream"); - - let output = String::from_utf8(output).expect("settle output is utf-8"); - assert_eq!(output.matches("权威最终回复").count(), 1); - assert!(output.contains("父 Agent 回复已完整流式输出")); - assert!(output.contains(SWARM_TURN_REPORT_PREFIX)); - - let mut fallback = Vec::new(); - print_settled_parent_reply( - "code-prototype", - "session-test", - Some("未流过的权威回复"), - &SwarmRuntimeObserver::default(), - &mut fallback, - ) - .expect("print authoritative fallback"); - let fallback = String::from_utf8(fallback).expect("fallback output is utf-8"); - assert!(fallback.contains("Agent> 未流过的权威回复")); - } - - #[test] - fn response_stream_status_reports_only_status_sequence_and_char_count() { - let body = "PRIVATE_RESPONSE_BODY"; - let stream = response_stream( - "slot-private", - 17, - 8, - AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, - body, - ); - let mut output = Vec::new(); - print_runtime_response_stream_status(Some(&stream), &mut output) - .expect("print response stream status"); - let output = String::from_utf8(output).expect("status output is utf-8"); - - assert_eq!( - output.trim(), - format!( - "[回复流] status=ready sequence=8 chars={}", - body.chars().count() - ) - ); - assert!(!output.contains(body)); - assert!(!output.contains("slot-private")); - } - - #[test] - fn input_channel_preserves_lines_and_eof() { - let (tx, rx) = mpsc::channel(); - tx.send(SwarmInputEvent::Line("hello swarm".to_string())) - .expect("send line"); - tx.send(SwarmInputEvent::Eof).expect("send eof"); - assert_eq!( - receive_swarm_chat_line(&rx).expect("read line").as_deref(), - Some("hello swarm") - ); - assert_eq!(receive_swarm_chat_line(&rx).expect("read eof"), None); - } - - #[test] - fn confirmation_prompt_defers_to_bare_goal_status_without_deciding_action() { - let root = std::env::temp_dir().join(format!( - "swarm-goal-confirmation-{}-{}", - std::process::id(), - unix_millis() - )); - init_local_game_project_at(&root, "project-1", "Goal 确认提示测试") - .expect("initialize Goal prompt project"); - let (tx, rx) = mpsc::channel(); - tx.send(SwarmInputEvent::Line("/goal".to_string())) - .expect("send bare Goal status"); - let mut output = Vec::new(); - - let decision = prompt_swarm_decision( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &rx, - &mut output, - "", - ) - .expect("handle Goal status during confirmation"); - assert!(matches!(decision, SwarmPromptDecision::Deferred)); - let output = String::from_utf8(output).expect("prompt output is utf-8"); - assert!(output.contains("当前尚未设置持久目标")); - assert!(!output.contains("[已批准]")); - assert!(!output.contains("[已拒绝]")); - - fs::remove_dir_all(root).ok(); - } - - #[test] - fn event_deduplication_keeps_phase_and_detail_changes() { - let base = serde_json::json!({ - "agentId": "code-prototype", - "taskId": "task-1", - "sessionId": "session-1", - "runId": "run-1", - "eventType": "observation", - "status": "running", - "phase": "action", - "summary": "工具观察", - "detail": "第一条", - "updatedAt": 100, - }); - let first = serde_json::from_value::(base.clone()) - .expect("deserialize first event"); - let mut changed = base; - changed["phase"] = serde_json::json!("observation"); - changed["detail"] = serde_json::json!("第二条"); - let second = - serde_json::from_value::(changed).expect("deserialize second event"); - assert_ne!(runtime_event_key(&first), runtime_event_key(&second)); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/commands.rs new file mode 100644 index 000000000..a9a6d90b8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/commands.rs @@ -0,0 +1,148 @@ +use super::*; + +pub(super) fn print_swarm_mcp_status(root: &Path, output: &mut W) -> Result<(), String> { + match read_external_agent_runner_mcp_catalog(root) { + Ok(catalog) => print_swarm_mcp_catalog(&catalog, output), + Err(error) => writeln!(output, "[MCP] 状态读取失败:{error}") + .map_err(|write_error| format!("写入终端失败:{write_error}")), + } +} + +pub(super) fn print_swarm_mcp_catalog( + catalog: &GameCreatorMcpCatalog, + output: &mut W, +) -> Result<(), String> { + writeln!( + output, + "[MCP] catalog={} servers={} tools={}", + catalog.fingerprint.chars().take(12).collect::(), + catalog.servers.len(), + catalog.tools.len(), + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + for server in &catalog.servers { + writeln!( + output, + " server={} transport={} enabled={} connected={} required={} tools={}{}", + server.server_id, + server.transport, + server.enabled, + server.connected, + server.required, + server.tool_count, + server + .error + .as_deref() + .map(|error| format!(" error={error}")) + .unwrap_or_default(), + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + for tool in &catalog.tools { + let description = sanitize_prompt_context(&tool.description) + .chars() + .take(180) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + writeln!( + output, + " tool={}/{} approval={} readOnly={} schema={}{}", + tool.server_id, + tool.name, + tool.effective_approval_mode, + tool.read_only_hint, + serde_json::to_string(&tool.input_schema) + .unwrap_or_else(|_| "{}".to_string()) + .chars() + .take(600) + .collect::(), + if description.is_empty() { + String::new() + } else { + format!(" description={description}") + }, + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +pub(super) fn print_swarm_agents(root: &Path, output: &mut W) -> Result<(), String> { + writeln!(output, "静态 Agent:").map_err(|error| format!("写入终端失败:{error}"))?; + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + writeln!( + output, + "- {} / {}: {} ({})", + group.label, role.role, role.task_id, role.id + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + } + let dynamic = read_game_creator_agent_runtimes_at(root)? + .into_iter() + .filter(|runtime| runtime.state.agent_id.starts_with("child-")) + .collect::>(); + if !dynamic.is_empty() { + writeln!(output, "动态隔离 Agent:").map_err(|error| format!("写入终端失败:{error}"))?; + for runtime in dynamic { + writeln!( + output, + "- {} <- {} run={} delegation={} status={}/{}", + runtime.state.agent_id, + runtime + .state + .parent_agent_id + .as_deref() + .unwrap_or("unknown"), + runtime.state.run_id, + runtime.state.delegation_id.as_deref().unwrap_or("unknown"), + runtime.state.status, + runtime.state.phase + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + } + Ok(()) +} + +pub(super) fn print_swarm_status(root: &Path, output: &mut W) -> Result<(), String> { + let runtimes = read_game_creator_agent_runtimes_at(root)?; + for runtime in runtimes.iter().filter(|runtime| { + !runtime.state.run_id.is_empty() + || runtime.task_queue.pending > 0 + || runtime.task_queue.running > 0 + || runtime.task_queue.waiting_for_confirmation > 0 + || runtime.task_queue.waiting_for_user_input > 0 + }) { + print_runtime_state(&runtime.state, &runtime.task_queue, output)?; + print_runtime_response_stream_status(runtime.response_stream.as_ref(), output)?; + } + if runtimes + .iter() + .all(|runtime| runtime.state.run_id.is_empty()) + { + writeln!(output, "当前没有 Agent Runtime 记录。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +pub(super) fn print_runtime_response_stream_status( + stream: Option<&AgentRuntimeResponseStream>, + output: &mut W, +) -> Result<(), String> { + let Some(stream) = stream else { + return Ok(()); + }; + writeln!( + output, + "[回复流] status={} sequence={} chars={}", + stream.status, + stream.sequence, + stream.accumulated_text.chars().count() + ) + .map_err(|error| format!("写入终端失败:{error}")) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/conversation.rs new file mode 100644 index 000000000..99ccc026b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/conversation.rs @@ -0,0 +1,260 @@ +use super::*; + +pub(super) const SWARM_CHAT_HISTORY_LIMIT: usize = 50; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct SwarmTurnConversationMetrics { + pub(super) new_assistant_message_count: usize, + pub(super) final_reply_chars: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SwarmRecoveredAssistant { + pub(super) run_id: String, + pub(super) finalization_id: String, + pub(super) message_id: String, + pub(super) content: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SwarmTurnConversationBaseline { + pub(super) previous_message_count: usize, + pub(super) parent_run_id: String, + pub(super) recovered_assistant: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SwarmTurnConversationSnapshot { + pub(super) metrics: SwarmTurnConversationMetrics, + pub(super) final_reply: Option, + pub(super) recovered_before_observation: bool, +} + +pub(super) fn handle_swarm_context_compaction( + root: &Path, + parent_agent_id: &str, + output: &mut W, +) -> Result<(), String> { + let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + match compact_external_agent_runner_context( + root, + parent_agent_id, + conversation.session_id.as_deref(), + ) { + Ok(result) => writeln!( + output, + "[上下文压缩] revision={} reused={} estimated={}->{} covered={}/{}/{}", + result.revision, + result.reused, + result.estimated_tokens_before, + result.estimated_tokens_after, + result.covered_agent_messages, + result.covered_project_messages, + result.covered_observations, + ), + Err(error) => writeln!(output, "[上下文压缩失败] {error}"), + } + .map_err(|error| format!("写入终端失败:{error}")) +} + +pub(super) fn print_conversation_history( + root: &Path, + parent_agent_id: &str, + output: &mut W, +) -> Result<(), String> { + let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + writeln!( + output, + "[历史] session={} messages={}", + conversation.session_id.as_deref().unwrap_or("unknown"), + conversation.messages.len() + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let start = conversation + .messages + .len() + .saturating_sub(SWARM_CHAT_HISTORY_LIMIT); + for message in &conversation.messages[start..] { + let label = if message.role == "assistant" { + "Agent" + } else if message.role == "user" { + "你" + } else { + message.role.as_str() + }; + writeln!(output, "{label}> {}", message.content) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +pub(super) fn new_swarm_turn_conversation_baseline( + previous_message_count: usize, + parent_run_id: impl Into, +) -> SwarmTurnConversationBaseline { + SwarmTurnConversationBaseline { + previous_message_count, + parent_run_id: parent_run_id.into(), + recovered_assistant: None, + } +} + +pub(super) fn capture_recovered_swarm_assistant_at( + root: &Path, + parent_agent_id: &str, + session_id: &str, + baseline: &mut SwarmTurnConversationBaseline, +) -> Result<(), String> { + if baseline.parent_run_id.trim().is_empty() || baseline.recovered_assistant.is_some() { + return Ok(()); + } + let conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + if baseline.previous_message_count > conversation.messages.len() { + return Err("Swarm turn 对话 baseline 超出当前 Session 消息数".to_string()); + } + if baseline.previous_message_count < conversation.messages.len() { + return Ok(()); + } + let Some(journal) = read_game_creator_agent_runtime_finalization_journal( + root, + parent_agent_id, + &baseline.parent_run_id, + )? + else { + return Ok(()); + }; + if journal.agent_id != parent_agent_id + || journal.session_id != session_id + || journal.run_id != baseline.parent_run_id + { + return Err("Swarm 恢复 finalization 与目标 parent Session/run 不匹配".to_string()); + } + if !game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { + return Ok(()); + } + baseline.recovered_assistant = Some(SwarmRecoveredAssistant { + run_id: journal.run_id, + finalization_id: journal.finalization_id, + message_id: journal.message_id, + content: journal.response, + }); + Ok(()) +} + +pub(super) fn read_turn_conversation_snapshot( + root: &Path, + parent_agent_id: &str, + session_id: &str, + baseline: &SwarmTurnConversationBaseline, +) -> Result { + let conversation = + read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?; + if baseline.previous_message_count > conversation.messages.len() { + return Err("Swarm turn 对话 baseline 超出当前 Session 消息数".to_string()); + } + let (mut metrics, final_reply) = summarize_new_assistant_messages( + conversation + .messages + .iter() + .skip(baseline.previous_message_count) + .map(|message| (message.role.as_str(), message.content.as_str())), + ); + let mut final_reply = final_reply.map(str::to_string); + let recovered_before_observation = baseline.recovered_assistant.is_some(); + if let Some(recovered) = baseline.recovered_assistant.as_ref() { + if recovered.run_id != baseline.parent_run_id + || recovered.finalization_id.trim().is_empty() + || recovered.message_id.trim().is_empty() + { + return Err("Swarm 恢复 assistant 身份不完整".to_string()); + } + if metrics.new_assistant_message_count != 0 { + return Err("Swarm 恢复 assistant 与 baseline 后的新回复重叠".to_string()); + } + metrics.new_assistant_message_count = metrics.new_assistant_message_count.saturating_add(1); + if final_reply.is_none() { + metrics.final_reply_chars = recovered.content.chars().count(); + final_reply = Some(recovered.content.clone()); + } + } + Ok(SwarmTurnConversationSnapshot { + metrics, + final_reply, + recovered_before_observation, + }) +} + +pub(super) fn read_turn_conversation_metrics( + root: &Path, + parent_agent_id: &str, + session_id: &str, + baseline: &SwarmTurnConversationBaseline, +) -> Result { + read_turn_conversation_snapshot(root, parent_agent_id, session_id, baseline) + .map(|snapshot| snapshot.metrics) +} + +pub(super) fn summarize_new_assistant_messages<'a>( + messages: impl IntoIterator, +) -> (SwarmTurnConversationMetrics, Option<&'a str>) { + let mut new_assistant_message_count = 0; + let mut final_reply = None; + for (role, content) in messages { + if role == "assistant" { + new_assistant_message_count += 1; + final_reply = Some(content); + } + } + ( + SwarmTurnConversationMetrics { + new_assistant_message_count, + final_reply_chars: final_reply.map_or(0, |reply| reply.chars().count()), + }, + final_reply, + ) +} + +pub(super) fn print_new_parent_reply( + root: &Path, + parent_agent_id: &str, + session_id: &str, + baseline: &SwarmTurnConversationBaseline, + output: &mut W, + observer: &mut SwarmRuntimeObserver, +) -> Result { + let snapshot = read_turn_conversation_snapshot(root, parent_agent_id, session_id, baseline)?; + observer.close_response_line(output)?; + if snapshot.recovered_before_observation { + writeln!(output, "[本轮结束] 父 Agent 回复已在恢复前持久化。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } else { + print_settled_parent_reply( + parent_agent_id, + session_id, + snapshot.final_reply.as_deref(), + observer, + output, + )?; + } + Ok(snapshot.metrics) +} + +pub(super) fn print_settled_parent_reply( + parent_agent_id: &str, + session_id: &str, + reply: Option<&str>, + observer: &SwarmRuntimeObserver, + output: &mut W, +) -> Result<(), String> { + let Some(reply) = reply else { + return writeln!(output, "[本轮结束] 父 Agent 未产生新的最终回复。") + .map_err(|error| format!("写入终端失败:{error}")); + }; + if observer.parent_reply_was_fully_streamed(parent_agent_id, session_id, reply) { + writeln!(output, "[本轮结束] 父 Agent 回复已完整流式输出。") + .map_err(|error| format!("写入终端失败:{error}")) + } else { + writeln!(output, "\nAgent> {reply}").map_err(|error| format!("写入终端失败:{error}")) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/goal_commands.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/goal_commands.rs new file mode 100644 index 000000000..4f3e6c5ce --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/goal_commands.rs @@ -0,0 +1,189 @@ +use super::*; + +#[derive(Debug, Eq, PartialEq)] +pub(super) enum SwarmGoalCommand { + Status, + Start(String), + Edit(String), + Pause, + Resume, + Clear, +} + +#[derive(Debug, Eq, PartialEq)] +pub(super) struct SwarmGoalObservation { + pub(super) session_id: String, + pub(super) run_id: String, + pub(super) previous_message_count: usize, +} + +pub(super) fn handle_swarm_goal_command( + root: &Path, + parent_agent_id: &str, + command: SwarmGoalCommand, + output: &mut W, +) -> Result, String> { + match execute_swarm_goal_command(root, parent_agent_id, command, output) { + Ok(observation) => Ok(observation), + Err(error) => { + print_swarm_goal_error(output, &error)?; + Ok(None) + } + } +} + +pub(super) fn execute_swarm_goal_command( + root: &Path, + parent_agent_id: &str, + command: SwarmGoalCommand, + output: &mut W, +) -> Result, String> { + let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let session_id = conversation + .session_id + .clone() + .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + let previous_message_count = conversation.messages.len(); + let current_goal = read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)?; + let project_path = root.display().to_string(); + + match command { + SwarmGoalCommand::Status => { + print_swarm_goal_status(&session_id, current_goal.as_ref(), output)?; + Ok(None) + } + SwarmGoalCommand::Start(outcome) => { + let requested_run_id = format!("swarm-goal-{parent_agent_id}-{}", unix_millis()); + let result = start_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + Some(session_id.clone()), + outcome.clone(), + Vec::new(), + vec![outcome], + requested_run_id, + )?; + print_swarm_goal_mutation("已启动", &result, output)?; + Ok(Some(SwarmGoalObservation { + session_id, + run_id: result.goal.run_id.clone(), + previous_message_count, + })) + } + SwarmGoalCommand::Edit(outcome) => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = edit_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id.clone(), + goal.goal_id, + goal.revision, + outcome.clone(), + Vec::new(), + vec![outcome], + )?; + print_swarm_goal_mutation("已编辑", &result, output)?; + Ok( + (result.goal.status == AGENT_GOAL_STATUS_ACTIVE).then_some(SwarmGoalObservation { + session_id, + run_id: result.goal.run_id.clone(), + previous_message_count, + }), + ) + } + SwarmGoalCommand::Pause => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = pause_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id, + goal.goal_id, + goal.revision, + )?; + print_swarm_goal_mutation("已暂停", &result, output)?; + Ok(None) + } + SwarmGoalCommand::Resume => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = resume_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id.clone(), + goal.goal_id, + goal.revision, + )?; + print_swarm_goal_mutation("已恢复", &result, output)?; + Ok(Some(SwarmGoalObservation { + session_id, + run_id: result.goal.run_id.clone(), + previous_message_count, + })) + } + SwarmGoalCommand::Clear => { + let goal = current_goal.ok_or_else(|| "当前 Session 没有 Agent Goal".to_string())?; + let result = clear_game_creator_agent_goal( + project_path, + parent_agent_id.to_string(), + session_id, + goal.goal_id, + goal.revision, + )?; + print_swarm_goal_mutation("已清理", &result, output)?; + Ok(None) + } + } +} + +pub(super) fn print_swarm_goal_status( + session_id: &str, + goal: Option<&AgentGoalRecord>, + output: &mut W, +) -> Result<(), String> { + let Some(goal) = goal else { + return writeln!(output, "[Goal] session={session_id} 当前尚未设置持久目标。") + .map_err(|error| format!("写入终端失败:{error}")); + }; + writeln!( + output, + "[Goal] session={} goal={} run={} revision={} status={}", + goal.session_id, goal.goal_id, goal.run_id, goal.revision, goal.status + ) + .and_then(|_| writeln!(output, "[Goal 目标] {}", goal.outcome)) + .map_err(|error| format!("写入终端失败:{error}"))?; + for constraint in &goal.constraints { + writeln!(output, "[Goal 约束] {constraint}") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + for verification in &goal.verification { + writeln!(output, "[Goal 完成标准] {verification}") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + if let Some(error) = goal.error.as_deref() { + writeln!(output, "[Goal 错误] {error}") + .map_err(|write_error| format!("写入终端失败:{write_error}"))?; + } + Ok(()) +} + +pub(super) fn print_swarm_goal_mutation( + action: &str, + result: &AgentGoalMutationResult, + output: &mut W, +) -> Result<(), String> { + writeln!( + output, + "[Goal {action}] goal={} run={} revision={} status={} providerInterrupted={}", + result.goal.goal_id, + result.goal.run_id, + result.goal.revision, + result.goal.status, + result.provider_interrupted + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_goal_status(&result.goal.session_id, Some(&result.goal), output) +} + +pub(super) fn print_swarm_goal_error(output: &mut W, error: &str) -> Result<(), String> { + writeln!(output, "[Goal 失败] {error}") + .map_err(|write_error| format!("写入终端失败:{write_error}")) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs new file mode 100644 index 000000000..6556192d5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/input.rs @@ -0,0 +1,475 @@ +use super::*; + +#[derive(Debug, Eq, PartialEq)] +pub(super) enum SwarmChatInput { + Help, + Agents, + Status, + History, + Compact, + Mcp, + Goal(SwarmGoalCommand), + InvalidGoal(String), + Quit, + Message(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SwarmChatFlow { + Continue, + Exit, +} + +pub(super) enum SwarmInputEvent { + Line(String), + Eof, + Error(String), +} + +pub(super) enum SwarmPromptDecision { + Approve, + Reject, + Deferred, + InputClosed, + Quit, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SwarmNewRunLaunch<'a> { + ProjectSupervisor { + source: &'static str, + run_profile: &'a str, + }, + ExplicitParentDebug, +} + +pub(super) fn resolve_swarm_new_run_launch<'a>( + parent_agent_id: &str, + run_profile: &'a str, +) -> Result, String> { + if !matches!( + run_profile, + AGENT_RUNTIME_RUN_PROFILE_STANDARD | AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ) { + return Err(format!("不支持的 Agent Runtime Run Profile:{run_profile}")); + } + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + return Ok(SwarmNewRunLaunch::ProjectSupervisor { + source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + run_profile, + }); + } + if run_profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD { + return Err("--autonomous-game-build 仅支持 project-supervisor 总控入口".to_string()); + } + Ok(SwarmNewRunLaunch::ExplicitParentDebug) +} + +pub(super) fn run_game_creator_swarm_chat_with_input( + root: &Path, + parent_agent_id: &str, + run_profile: &str, + input: &Receiver, + output: &mut W, +) -> Result<(), String> { + let parent_agent_id = parent_agent_id.trim(); + if parent_agent_id.is_empty() { + return Err("parentAgentId 不能为空".to_string()); + } + let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?; + let project_path = root.display().to_string(); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + enforce_project_permission_policy(root, "agent.compact")?; + enforce_project_permission_policy(root, "agent.resume")?; + let _ = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let resumed = resume_game_creator_agent_background_tasks_at(root)?; + let existing_runtimes = read_game_creator_agent_runtimes_at(root)?; + + writeln!(output, "Agent Swarm Chat") + .and_then(|_| writeln!(output, "项目:{}", root.display())) + .and_then(|_| writeln!(output, "父 Agent:{parent_agent_id}")) + .and_then(|_| writeln!(output, "输入 /help 查看命令。")) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_conversation_history(root, parent_agent_id, output)?; + if !resumed.is_empty() { + writeln!(output, "[恢复扫描] 已检查 {} 个 Runtime。", resumed.len()) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + + if runtimes_are_busy(&existing_runtimes) { + writeln!(output, "[恢复] 检测到未收束 Runtime,继续观察现有任务。") + .map_err(|error| format!("写入终端失败:{error}"))?; + let before = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let session_id = before + .session_id + .as_deref() + .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + let parent_run_id = swarm_parent_runtime(parent_agent_id, session_id, &existing_runtimes) + .map(|runtime| runtime.state.run_id.as_str()) + .unwrap_or_default(); + let mut conversation_baseline = + new_swarm_turn_conversation_baseline(before.messages.len(), parent_run_id); + capture_recovered_swarm_assistant_at( + root, + parent_agent_id, + session_id, + &mut conversation_baseline, + )?; + let mut observer = SwarmRuntimeObserver::default(); + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + session_id, + conversation_baseline, + input, + output, + &mut observer, + SWARM_CHAT_POLL_INTERVAL, + SWARM_CHAT_SETTLE_WINDOW, + )?; + if outcome == SwarmTurnOutcome::Quit { + return print_swarm_chat_exit(output); + } + print_turn_outcome(outcome, output)?; + } + + loop { + write!(output, "\n你> ").map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + let Some(line) = receive_swarm_chat_line(input)? else { + writeln!(output, "\n已退出 Agent Swarm Chat。") + .map_err(|error| format!("写入终端失败:{error}"))?; + return Ok(()); + }; + let Some(command) = parse_swarm_chat_input(&line) else { + continue; + }; + match command { + SwarmChatInput::Help => print_swarm_chat_help(output)?, + SwarmChatInput::Agents => print_swarm_agents(root, output)?, + SwarmChatInput::Status => print_swarm_status(root, output)?, + SwarmChatInput::History => print_conversation_history(root, parent_agent_id, output)?, + SwarmChatInput::Compact => { + handle_swarm_context_compaction(root, parent_agent_id, output)? + } + SwarmChatInput::Mcp => print_swarm_mcp_status(root, output)?, + SwarmChatInput::Goal(command) => { + let mut observer = SwarmRuntimeObserver::seed(root)?; + let Some(observation) = + handle_swarm_goal_command(root, parent_agent_id, command, output)? + else { + continue; + }; + let conversation_baseline = new_swarm_turn_conversation_baseline( + observation.previous_message_count, + &observation.run_id, + ); + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + &observation.session_id, + conversation_baseline, + input, + output, + &mut observer, + SWARM_CHAT_POLL_INTERVAL, + SWARM_CHAT_SETTLE_WINDOW, + )?; + if outcome == SwarmTurnOutcome::Quit { + return print_swarm_chat_exit(output); + } + print_turn_outcome(outcome, output)?; + } + SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, + SwarmChatInput::Quit => { + writeln!( + output, + "已退出 Agent Swarm Chat;后台 Runner 和已投递任务保持运行。" + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + return Ok(()); + } + SwarmChatInput::Message(message) => { + let before = + read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?; + let session_id = before + .session_id + .clone() + .ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?; + let mut observer = SwarmRuntimeObserver::seed(root)?; + if let Some(goal) = + read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)? + { + match goal.status.as_str() { + AGENT_GOAL_STATUS_ACTIVE => { + let steer_id = format!("swarm-goal-steer-{}", unix_millis()); + let result = steer_game_creator_agent_runtime_task( + project_path.clone(), + parent_agent_id.to_string(), + session_id.clone(), + goal.run_id.clone(), + steer_id.clone(), + message, + )?; + writeln!( + output, + "[Goal 已追加] run={} steer={} providerInterrupted={}", + goal.run_id, steer_id, result.provider_interrupted + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let conversation_baseline = new_swarm_turn_conversation_baseline( + before.messages.len(), + &goal.run_id, + ); + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + &session_id, + conversation_baseline, + input, + output, + &mut observer, + SWARM_CHAT_POLL_INTERVAL, + SWARM_CHAT_SETTLE_WINDOW, + )?; + if outcome == SwarmTurnOutcome::Quit { + return print_swarm_chat_exit(output); + } + print_turn_outcome(outcome, output)?; + continue; + } + AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => { + print_swarm_goal_error( + output, + "当前 Goal 已暂停;请先输入 /goal resume。", + )?; + continue; + } + AGENT_GOAL_STATUS_CLEARING => { + print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?; + continue; + } + AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => { + print_swarm_goal_error( + output, + "当前 Goal 需要人工 reconciliation,暂不接受新消息。", + )?; + continue; + } + AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {} + status => { + print_swarm_goal_error( + output, + &format!("当前 Goal 状态未知,已阻止发送:{status}"), + )?; + continue; + } + } + } + let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis()); + let started = match new_run_launch { + SwarmNewRunLaunch::ProjectSupervisor { + source, + run_profile, + } => start_game_creator_supervisor_background_task_for_session_at( + root, + Some(&session_id), + &message, + &requested_run_id, + source, + run_profile, + )?, + SwarmNewRunLaunch::ExplicitParentDebug => { + start_game_creator_agent_runtime_task( + project_path.clone(), + parent_agent_id.to_string(), + Some(session_id.clone()), + message, + requested_run_id.clone(), + )? + } + }; + writeln!( + output, + "[已投递] agent={} session={} run={}", + parent_agent_id, started.state.session_id, requested_run_id + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let conversation_baseline = new_swarm_turn_conversation_baseline( + before.messages.len(), + &started.state.run_id, + ); + let outcome = wait_for_swarm_turn( + root, + parent_agent_id, + &session_id, + conversation_baseline, + input, + output, + &mut observer, + SWARM_CHAT_POLL_INTERVAL, + SWARM_CHAT_SETTLE_WINDOW, + )?; + if outcome == SwarmTurnOutcome::Quit { + return print_swarm_chat_exit(output); + } + print_turn_outcome(outcome, output)?; + } + } + } +} + +pub(super) fn receive_swarm_chat_line( + input: &Receiver, +) -> Result, String> { + match input.recv() { + Ok(SwarmInputEvent::Line(line)) => Ok(Some(line)), + Ok(SwarmInputEvent::Eof) | Err(_) => Ok(None), + Ok(SwarmInputEvent::Error(error)) => Err(format!("读取终端输入失败:{error}")), + } +} + +pub(super) fn prompt_swarm_decision( + root: &Path, + parent_agent_id: &str, + input: &Receiver, + output: &mut W, + prompt: &str, +) -> Result { + write!(output, "{prompt}").map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + loop { + let Some(line) = receive_swarm_chat_line(input)? else { + return Ok(SwarmPromptDecision::InputClosed); + }; + match line.to_ascii_lowercase().as_str() { + "approve" | "yes" | "y" | "批准" => return Ok(SwarmPromptDecision::Approve), + "reject" | "no" | "n" | "拒绝" => return Ok(SwarmPromptDecision::Reject), + "/quit" | "/exit" => return Ok(SwarmPromptDecision::Quit), + _ => { + if let Some(command) = parse_swarm_chat_input(&line) { + match command { + SwarmChatInput::Goal(command) => { + let _ = + handle_swarm_goal_command(root, parent_agent_id, command, output)?; + return Ok(SwarmPromptDecision::Deferred); + } + SwarmChatInput::InvalidGoal(error) => { + print_swarm_goal_error(output, &error)?; + } + SwarmChatInput::Compact => { + handle_swarm_context_compaction(root, parent_agent_id, output)?; + return Ok(SwarmPromptDecision::Deferred); + } + SwarmChatInput::Mcp => { + print_swarm_mcp_status(root, output)?; + } + _ => {} + } + } + write!(output, "请输入 approve 或 reject:") + .map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + } + } + } +} + +pub(super) fn print_swarm_chat_exit(output: &mut W) -> Result<(), String> { + writeln!( + output, + "已退出 Agent Swarm Chat;后台 Runner 和已投递任务保持运行。" + ) + .map_err(|error| format!("写入终端失败:{error}")) +} + +pub(super) fn parse_swarm_chat_input(input: &str) -> Option { + let input = input.trim(); + if input.is_empty() { + return None; + } + if let Some(rest) = input.strip_prefix("/goal") { + if rest.is_empty() { + return Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)); + } + if !rest.chars().next().is_some_and(char::is_whitespace) { + return Some(SwarmChatInput::InvalidGoal( + "未知 /goal 命令;输入 /help 查看支持的 Goal 命令。".to_string(), + )); + } + return Some(parse_swarm_goal_command(rest.trim())); + } + Some(match input { + "/help" => SwarmChatInput::Help, + "/agents" => SwarmChatInput::Agents, + "/status" => SwarmChatInput::Status, + "/history" => SwarmChatInput::History, + "/compact" => SwarmChatInput::Compact, + "/mcp" => SwarmChatInput::Mcp, + "/quit" | "/exit" => SwarmChatInput::Quit, + value => SwarmChatInput::Message(value.to_string()), + }) +} + +pub(super) fn parse_swarm_goal_command(input: &str) -> SwarmChatInput { + if input.is_empty() || input == "status" { + return SwarmChatInput::Goal(SwarmGoalCommand::Status); + } + if input == "pause" { + return SwarmChatInput::Goal(SwarmGoalCommand::Pause); + } + if input == "resume" { + return SwarmChatInput::Goal(SwarmGoalCommand::Resume); + } + if input == "clear" { + return SwarmChatInput::Goal(SwarmGoalCommand::Clear); + } + if let Some(outcome) = input.strip_prefix("edit") { + if outcome.is_empty() { + return SwarmChatInput::InvalidGoal("用法:/goal edit <目标>".to_string()); + } + if outcome.chars().next().is_some_and(char::is_whitespace) { + let outcome = outcome.trim(); + return if outcome.is_empty() { + SwarmChatInput::InvalidGoal("用法:/goal edit <目标>".to_string()) + } else { + SwarmChatInput::Goal(SwarmGoalCommand::Edit(outcome.to_string())) + }; + } + } + for command in ["status", "pause", "resume", "clear"] { + if input + .strip_prefix(command) + .is_some_and(|rest| rest.chars().next().is_some_and(char::is_whitespace)) + { + return SwarmChatInput::InvalidGoal(format!("/goal {command} 不接受额外参数")); + } + } + SwarmChatInput::Goal(SwarmGoalCommand::Start(input.to_string())) +} + +pub(super) fn print_swarm_chat_help(output: &mut W) -> Result<(), String> { + writeln!(output, "/agents 查看静态 Agent 与动态 child") + .and_then(|_| writeln!(output, "/status 查看全部 Runtime 状态")) + .and_then(|_| writeln!(output, "/history 查看父 Agent 当前 Session 历史")) + .and_then(|_| writeln!(output, "/compact 压缩父 Agent 当前空闲 Session 历史")) + .and_then(|_| writeln!(output, "/mcp 查看 Runner MCP server 与工具目录")) + .and_then(|_| writeln!(output, "/goal <目标> 启动当前 Session 的持久 Goal")) + .and_then(|_| writeln!(output, "/goal 查看当前 Goal")) + .and_then(|_| writeln!(output, "/goal status 查看当前 Goal")) + .and_then(|_| writeln!(output, "/goal edit <目标> 编辑当前 Goal")) + .and_then(|_| writeln!(output, "/goal pause 暂停当前 Goal")) + .and_then(|_| writeln!(output, "/goal resume 恢复当前 Goal")) + .and_then(|_| writeln!(output, "/goal clear 清理当前 Goal")) + .and_then(|_| writeln!(output, "/help 查看命令")) + .and_then(|_| writeln!(output, "/quit 退出终端观察客户端")) + .map_err(|error| format!("写入终端失败:{error}")) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/observer.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/observer.rs new file mode 100644 index 000000000..f728282f7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/observer.rs @@ -0,0 +1,843 @@ +use super::*; + +pub(super) const SWARM_CHAT_PLAN_STEP_LIMIT: usize = 8; + +#[derive(Default)] +pub(super) struct SwarmRuntimeObserver { + pub(super) state_signatures: BTreeMap, + pub(super) seen_events: BTreeSet, + pub(super) handled_confirmations: BTreeSet, + pub(super) handled_user_input_requests: BTreeSet, + pub(super) user_input_response_ids: BTreeMap, + pub(super) response_streams: BTreeMap, + pub(super) open_response_line: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SwarmResponseStreamIdentity { + pub(super) task_id: String, + pub(super) session_id: String, + pub(super) run_id: String, + pub(super) request_slot: String, + pub(super) applied_steer_cursor: u64, + pub(super) response_revision: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SwarmResponseStreamCursor { + pub(super) identity: SwarmResponseStreamIdentity, + pub(super) session_id: String, + pub(super) sequence: u64, + pub(super) status: String, + pub(super) accumulated_text: String, + pub(super) printed_accumulated_text: Option, + pub(super) connected: bool, + pub(super) rejected_snapshot: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SwarmResponseStreamLine { + pub(super) agent_id: String, + pub(super) identity: SwarmResponseStreamIdentity, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SwarmRejectedResponseStreamSnapshot { + pub(super) session_id: String, + pub(super) sequence: u64, + pub(super) status: String, + pub(super) accumulated_text: String, +} + +pub(super) enum SwarmConfirmationResolution { + None, + Handled, + InputClosed, + Quit, +} + +impl SwarmResponseStreamIdentity { + pub(super) fn from_stream(stream: &AgentRuntimeResponseStream) -> Self { + Self { + task_id: stream.task_id.clone(), + session_id: stream.session_id.clone(), + run_id: stream.run_id.clone(), + request_slot: stream.request_slot.clone(), + applied_steer_cursor: stream.applied_steer_cursor, + response_revision: stream.response_revision, + } + } +} + +impl SwarmResponseStreamCursor { + pub(super) fn seeded(stream: &AgentRuntimeResponseStream) -> Self { + Self { + identity: SwarmResponseStreamIdentity::from_stream(stream), + session_id: stream.session_id.clone(), + sequence: stream.sequence, + status: stream.status.clone(), + accumulated_text: stream.accumulated_text.clone(), + printed_accumulated_text: stream.accumulated_text.is_empty().then(String::new), + connected: true, + rejected_snapshot: None, + } + } + + pub(super) fn fresh(stream: &AgentRuntimeResponseStream) -> Self { + Self { + identity: SwarmResponseStreamIdentity::from_stream(stream), + session_id: stream.session_id.clone(), + sequence: stream.sequence, + status: stream.status.clone(), + accumulated_text: stream.accumulated_text.clone(), + printed_accumulated_text: None, + connected: true, + rejected_snapshot: None, + } + } +} + +impl SwarmRejectedResponseStreamSnapshot { + pub(super) fn from_stream(stream: &AgentRuntimeResponseStream) -> Self { + Self { + session_id: stream.session_id.clone(), + sequence: stream.sequence, + status: stream.status.clone(), + accumulated_text: stream.accumulated_text.clone(), + } + } +} + +pub(super) fn swarm_response_stream_is_printable(stream: &AgentRuntimeResponseStream) -> bool { + matches!( + stream.status.as_str(), + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY + ) +} + +pub(super) fn swarm_response_stream_identity_reset_reason( + previous: &SwarmResponseStreamIdentity, + current: &SwarmResponseStreamIdentity, +) -> &'static str { + if previous.run_id != current.run_id { + "new-run" + } else if previous.session_id != current.session_id { + "new-session" + } else if previous.task_id != current.task_id { + "new-task" + } else if previous.applied_steer_cursor != current.applied_steer_cursor { + "new-steer-cursor" + } else if previous.request_slot != current.request_slot { + "new-request-slot" + } else { + "new-response-revision" + } +} + +impl SwarmRuntimeObserver { + pub(super) fn seed(root: &Path) -> Result { + let mut observer = Self::default(); + for runtime in read_game_creator_agent_runtimes_at(root)? { + observer.state_signatures.insert( + runtime.state.agent_id.clone(), + runtime_state_signature(&runtime.state, &runtime.task_queue), + ); + if let Some(stream) = runtime.response_stream.as_ref() { + observer.response_streams.insert( + runtime.state.agent_id.clone(), + SwarmResponseStreamCursor::seeded(stream), + ); + } + for event in runtime.recent_events { + observer.seen_events.insert(runtime_event_key(&event)); + } + } + Ok(observer) + } + + pub(super) fn print_changes( + &mut self, + runtimes: &[AgentRuntimeResult], + output: &mut W, + ) -> Result { + let mut changed = false; + for runtime in runtimes { + let has_runtime_history = !runtime.state.run_id.is_empty() + || runtime.task_queue.total > 0 + || !runtime.recent_events.is_empty(); + if has_runtime_history { + let signature = runtime_state_signature(&runtime.state, &runtime.task_queue); + if self.state_signatures.get(&runtime.state.agent_id) != Some(&signature) { + changed = true; + self.close_response_line(output)?; + self.state_signatures + .insert(runtime.state.agent_id.clone(), signature); + print_runtime_state(&runtime.state, &runtime.task_queue, output)?; + } + } + for event in &runtime.recent_events { + if self.seen_events.insert(runtime_event_key(event)) { + changed = true; + self.close_response_line(output)?; + writeln!( + output, + "[事件] {} {} {}/{} {}{}", + event.agent_id, + event.event_type, + event.status, + event.phase, + event.summary, + event + .detail + .as_deref() + .map(|detail| format!(" | {detail}")) + .unwrap_or_default() + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + } + changed |= self.observe_response_stream( + &runtime.state.agent_id, + runtime.response_stream.as_ref(), + output, + )?; + } + Ok(changed) + } + + pub(super) fn observe_response_stream( + &mut self, + agent_id: &str, + stream: Option<&AgentRuntimeResponseStream>, + output: &mut W, + ) -> Result { + let previous = self.response_streams.remove(agent_id); + let Some(stream) = stream else { + let Some(mut cursor) = previous else { + return Ok(false); + }; + let changed = cursor.connected; + if changed { + cursor.connected = false; + if self.response_line_matches(agent_id, &cursor.identity) { + self.close_response_line(output)?; + } + } + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(changed); + }; + + let printable = swarm_response_stream_is_printable(stream); + let Some(previous) = previous else { + let reason = if stream.sequence == 0 && stream.accumulated_text.is_empty() { + "new-stream" + } else { + "reconnect" + }; + self.print_response_stream_reset(agent_id, stream, reason, output)?; + let mut cursor = SwarmResponseStreamCursor::fresh(stream); + if printable { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(true); + }; + + let identity = SwarmResponseStreamIdentity::from_stream(stream); + if previous.identity != identity { + let reason = swarm_response_stream_identity_reset_reason(&previous.identity, &identity); + self.print_response_stream_reset(agent_id, stream, reason, output)?; + let mut cursor = SwarmResponseStreamCursor::fresh(stream); + if printable { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(true); + } + + let exact_snapshot = previous.session_id == stream.session_id + && previous.sequence == stream.sequence + && previous.status == stream.status + && previous.accumulated_text == stream.accumulated_text; + let unchanged = previous.connected && exact_snapshot; + if unchanged { + self.response_streams.insert(agent_id.to_string(), previous); + return Ok(false); + } + + let non_monotonic_reason = if previous.session_id != stream.session_id { + Some("identity-conflict") + } else if stream.sequence < previous.sequence { + Some("sequence-rollback") + } else if stream.sequence == previous.sequence && !exact_snapshot { + Some("sequence-conflict") + } else { + None + }; + if let Some(reason) = non_monotonic_reason { + let rejected = SwarmRejectedResponseStreamSnapshot::from_stream(stream); + if previous.rejected_snapshot.as_ref() == Some(&rejected) { + self.response_streams.insert(agent_id.to_string(), previous); + return Ok(false); + } + self.print_response_stream_reset(agent_id, stream, reason, output)?; + let mut cursor = previous; + cursor.connected = false; + cursor.rejected_snapshot = Some(rejected); + self.response_streams.insert(agent_id.to_string(), cursor); + return Ok(true); + } + + let prefix_continuation = stream + .accumulated_text + .strip_prefix(&previous.accumulated_text); + let reset_reason = if !previous.connected { + Some("reconnect") + } else if prefix_continuation.is_none() { + Some("non-prefix-correction") + } else { + None + }; + if let Some(reason) = reset_reason { + self.print_response_stream_reset(agent_id, stream, reason, output)?; + } + + let mut cursor = SwarmResponseStreamCursor::fresh(stream); + if printable { + let previous_printed = previous.printed_accumulated_text.as_deref(); + let reset_requires_full = reset_reason.is_some_and(|reason| reason != "reconnect") + && previous_printed != Some(stream.accumulated_text.as_str()); + if previous_printed == Some(stream.accumulated_text.as_str()) { + cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); + } else if reset_requires_full { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } else if let Some(suffix) = prefix_continuation + .filter(|_| previous_printed == Some(previous.accumulated_text.as_str())) + { + self.write_response_stream_chunk(agent_id, &identity, suffix, output)?; + cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); + } else { + self.print_response_stream_full(agent_id, stream, &mut cursor, output)?; + } + } else { + cursor.printed_accumulated_text = previous + .printed_accumulated_text + .filter(|printed| printed == &stream.accumulated_text); + if self.response_line_matches(agent_id, &identity) { + self.close_response_line(output)?; + } + } + self.response_streams.insert(agent_id.to_string(), cursor); + Ok(true) + } + + pub(super) fn print_response_stream_full( + &mut self, + agent_id: &str, + stream: &AgentRuntimeResponseStream, + cursor: &mut SwarmResponseStreamCursor, + output: &mut W, + ) -> Result<(), String> { + self.write_response_stream_chunk( + agent_id, + &cursor.identity, + &stream.accumulated_text, + output, + )?; + cursor.printed_accumulated_text = Some(stream.accumulated_text.clone()); + Ok(()) + } + + pub(super) fn print_response_stream_reset( + &mut self, + agent_id: &str, + stream: &AgentRuntimeResponseStream, + reason: &str, + output: &mut W, + ) -> Result<(), String> { + self.close_response_line(output)?; + writeln!( + output, + "[回复流重置] agent={} run={} requestSlot={} revision={} sequence={} status={} chars={} reason={}", + agent_id, + stream.run_id, + stream.request_slot, + stream.response_revision, + stream.sequence, + stream.status, + stream.accumulated_text.chars().count(), + reason + ) + .map_err(|error| format!("写入终端失败:{error}")) + } + + pub(super) fn write_response_stream_chunk( + &mut self, + agent_id: &str, + identity: &SwarmResponseStreamIdentity, + chunk: &str, + output: &mut W, + ) -> Result<(), String> { + if chunk.is_empty() { + return Ok(()); + } + if !self.response_line_matches(agent_id, identity) { + self.close_response_line(output)?; + write!(output, "Agent[{agent_id}]> {chunk}") + .map_err(|error| format!("写入终端失败:{error}"))?; + self.open_response_line = Some(SwarmResponseStreamLine { + agent_id: agent_id.to_string(), + identity: identity.clone(), + }); + } else { + write!(output, "{chunk}").map_err(|error| format!("写入终端失败:{error}"))?; + } + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}")) + } + + pub(super) fn response_line_matches( + &self, + agent_id: &str, + identity: &SwarmResponseStreamIdentity, + ) -> bool { + self.open_response_line + .as_ref() + .is_some_and(|line| line.agent_id == agent_id && line.identity == *identity) + } + + pub(super) fn close_response_line(&mut self, output: &mut W) -> Result<(), String> { + if self.open_response_line.take().is_some() { + writeln!(output).map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + } + Ok(()) + } + + pub(super) fn parent_reply_was_fully_streamed( + &self, + parent_agent_id: &str, + session_id: &str, + reply: &str, + ) -> bool { + self.response_streams + .get(parent_agent_id) + .is_some_and(|cursor| { + cursor.session_id == session_id + && cursor.accumulated_text == reply + && cursor.printed_accumulated_text.as_deref() == Some(reply) + && matches!( + cursor.status.as_str(), + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY + | AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ) + }) + } + + pub(super) fn resolve_confirmations( + &mut self, + root: &Path, + parent_agent_id: &str, + runtimes: &[AgentRuntimeResult], + input: &Receiver, + output: &mut W, + ) -> Result { + for runtime in runtimes { + if runtime.state.status != "waiting-for-confirmation" { + continue; + } + let Some(pending) = runtime.state.pending_tool_action.as_ref() else { + continue; + }; + let key = format!( + "{}:{}:{}", + runtime.state.agent_id, runtime.state.run_id, pending.action_id + ); + if self.handled_confirmations.contains(&key) { + continue; + } + self.close_response_line(output)?; + writeln!( + output, + "\n[待确认] agent={} run={} action={} tool={}", + runtime.state.agent_id, runtime.state.run_id, pending.action_id, pending.tool + ) + .and_then(|_| { + if let Some(summary) = pending.input_summary.as_deref() { + writeln!(output, "输入摘要:{summary}") + } else { + Ok(()) + } + }) + .and_then(|_| write!(output, "输入 approve 或 reject:")) + .map_err(|error| format!("写入终端失败:{error}"))?; + let decision = prompt_swarm_decision(root, parent_agent_id, input, output, "")?; + let approved = match decision { + SwarmPromptDecision::Approve => true, + SwarmPromptDecision::Reject => false, + SwarmPromptDecision::Deferred => return Ok(SwarmConfirmationResolution::Handled), + SwarmPromptDecision::InputClosed => { + return Ok(SwarmConfirmationResolution::InputClosed) + } + SwarmPromptDecision::Quit => return Ok(SwarmConfirmationResolution::Quit), + }; + let project_path = root.display().to_string(); + if approved { + confirm_game_creator_agent_runtime_task( + project_path, + runtime.state.agent_id.clone(), + runtime.state.run_id.clone(), + pending.action_id.clone(), + "Agent Swarm Chat 终端批准".to_string(), + )?; + writeln!(output, "[已批准] {}", pending.action_id) + .map_err(|error| format!("写入终端失败:{error}"))?; + } else { + reject_game_creator_agent_runtime_task( + project_path, + runtime.state.agent_id.clone(), + runtime.state.run_id.clone(), + pending.action_id.clone(), + "Agent Swarm Chat 终端拒绝".to_string(), + )?; + writeln!(output, "[已拒绝] {}", pending.action_id) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + self.handled_confirmations.insert(key); + return Ok(SwarmConfirmationResolution::Handled); + } + Ok(SwarmConfirmationResolution::None) + } + + pub(super) fn resolve_user_input_requests( + &mut self, + root: &Path, + parent_agent_id: &str, + runtimes: &[AgentRuntimeResult], + input: &Receiver, + output: &mut W, + ) -> Result { + for runtime in runtimes { + let Some(request) = runtime.user_input_request.as_ref() else { + continue; + }; + if runtime.state.agent_id != parent_agent_id + || runtime.state.status != "waiting-for-user-input" + { + continue; + } + let key = format!( + "{}:{}:{}", + runtime.state.agent_id, runtime.state.run_id, request.request_id + ); + if self.handled_user_input_requests.contains(&key) { + continue; + } + self.close_response_line(output)?; + writeln!( + output, + "\n[Needs input] agent={} run={} request={}", + runtime.state.agent_id, runtime.state.run_id, request.request_id + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + let mut answers = BTreeMap::new(); + for question in &request.questions { + writeln!(output, "\n{}:{}", question.header, question.question) + .map_err(|error| format!("写入终端失败:{error}"))?; + for (index, option) in question.options.iter().enumerate() { + writeln!( + output, + " {}. {} - {}", + index + 1, + option.label, + option.description + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + loop { + write!( + output, + "请选择 1-{},或直接输入其他答案:", + question.options.len() + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + let Some(line) = receive_swarm_chat_line(input)? else { + return Ok(SwarmConfirmationResolution::InputClosed); + }; + if matches!(line.as_str(), "/quit" | "/exit") { + return Ok(SwarmConfirmationResolution::Quit); + } + if line == "/status" { + print_swarm_status(root, output)?; + continue; + } + if line == "/history" { + print_conversation_history(root, parent_agent_id, output)?; + continue; + } + let answer = line + .parse::() + .ok() + .and_then(|index| index.checked_sub(1)) + .and_then(|index| question.options.get(index)) + .map(|option| option.label.clone()) + .unwrap_or_else(|| line.trim().to_string()); + if answer.is_empty() { + writeln!(output, "回答不能为空。") + .map_err(|error| format!("写入终端失败:{error}"))?; + continue; + } + answers.insert(question.id.clone(), answer); + break; + } + } + let response_id = self + .user_input_response_ids + .entry(key.clone()) + .or_insert_with(|| { + format!("swarm-user-input-{}-{}", request.request_id, unix_millis()) + }) + .clone(); + answer_game_creator_agent_runtime_user_input_at( + root, + &runtime.state.agent_id, + &runtime.state.run_id, + &request.action_id, + &request.request_id, + &response_id, + answers, + )?; + writeln!(output, "[已回答] {}", request.request_id) + .map_err(|error| format!("写入终端失败:{error}"))?; + self.handled_user_input_requests.insert(key); + return Ok(SwarmConfirmationResolution::Handled); + } + Ok(SwarmConfirmationResolution::None) + } +} + +pub(super) fn print_runtime_state( + state: &AgentRuntimeState, + queue: &AgentRuntimeTaskQueueSummary, + output: &mut W, +) -> Result<(), String> { + let relation = state + .parent_agent_id + .as_deref() + .map(|parent| { + format!( + " parent={parent} delegation={}", + state.delegation_id.as_deref().unwrap_or("unknown") + ) + }) + .unwrap_or_default(); + writeln!( + output, + "[状态] {} {}/{} run={} queue={}/{}/{}/{}{} | {}", + state.agent_id, + state.status, + state.phase, + state.run_id, + queue.pending, + queue.running, + queue.waiting_for_confirmation, + queue.waiting_for_user_input, + relation, + state.current_action + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + + let completed = state + .plan_steps + .iter() + .filter(|step| step.status == "completed") + .count(); + let current_step = runtime_current_plan_step(state) + .map(|step| { + format!( + "#{} [{}] {}", + step.index.saturating_add(1), + runtime_cli_value(&step.status), + runtime_cli_value(&step.title) + ) + }) + .unwrap_or_else(|| "-".to_string()); + writeln!( + output, + "[计划] revision={} completed={}/{} current={} | waiting={} | next={}", + state.plan_revision, + completed, + state.plan_steps.len(), + current_step, + runtime_cli_value(&state.waiting_on), + runtime_cli_value(&state.next_step) + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + if !state.plan_explanation.trim().is_empty() { + writeln!( + output, + " [计划说明] {}", + runtime_cli_value(&state.plan_explanation) + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + writeln!( + output, + "[上下文] estimated={}/{} actual={}/{}/{} compaction={} last={}", + state.context_usage.estimated_input_tokens, + state.context_usage.auto_compact_token_limit, + state + .context_usage + .last_prompt_tokens + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + state + .context_usage + .last_completion_tokens + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + state + .context_usage + .last_total_tokens + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + state.context_usage.compaction_revision, + state + .context_usage + .last_compacted_at + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + + for step in state.plan_steps.iter().take(SWARM_CHAT_PLAN_STEP_LIMIT) { + writeln!( + output, + " [计划步骤] #{} [{}] {}", + step.index.saturating_add(1), + runtime_cli_value(&step.status), + runtime_cli_value(&step.title) + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + if state.plan_steps.len() > SWARM_CHAT_PLAN_STEP_LIMIT { + writeln!( + output, + " [计划] 另有 {} 条步骤未显示", + state.plan_steps.len() - SWARM_CHAT_PLAN_STEP_LIMIT + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } + Ok(()) +} + +pub(super) fn runtime_current_plan_step( + state: &AgentRuntimeState, +) -> Option<&AgentRuntimePlanStep> { + state + .active_plan_step_index + .and_then(|active_index| { + state + .plan_steps + .iter() + .find(|step| step.index == active_index) + .or_else(|| state.plan_steps.get(active_index as usize)) + }) + .or_else(|| { + state.plan_steps.iter().find(|step| { + matches!( + step.status.as_str(), + "active" | "in_progress" | "running" | "waiting-for-confirmation" + ) + }) + }) + .or_else(|| { + state + .plan_steps + .iter() + .find(|step| step.status == "pending") + }) +} + +pub(super) fn runtime_cli_value(value: &str) -> &str { + let value = value.trim(); + if value.is_empty() { + "-" + } else { + value + } +} + +pub(super) fn runtime_state_signature( + state: &AgentRuntimeState, + queue: &AgentRuntimeTaskQueueSummary, +) -> String { + let completed_plan_steps = state + .plan_steps + .iter() + .filter(|step| step.status == "completed") + .count(); + let current_plan_step = runtime_current_plan_step(state) + .map(|step| format!("{}:{}:{}", step.index, step.status, step.title)) + .unwrap_or_default(); + let mut signature = format!( + "{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", + state.run_id, + state.status, + state.phase, + state.current_action, + state.updated_at, + queue.pending, + queue.running, + queue.waiting_for_confirmation, + queue.waiting_for_user_input, + queue.updated_at, + state.plan_revision, + state + .active_plan_step_index + .map(|index| index.to_string()) + .unwrap_or_default(), + completed_plan_steps, + state.plan_steps.len(), + current_plan_step, + state.waiting_on, + state.next_step + ); + signature.push(':'); + signature.push_str(&state.plan_explanation); + signature.push(':'); + signature.push_str(&format!( + "{}:{}:{:?}:{:?}:{}:{:?}", + state.context_usage.estimated_input_tokens, + state.context_usage.auto_compact_token_limit, + state.context_usage.last_prompt_tokens, + state.context_usage.last_completion_tokens, + state.context_usage.compaction_revision, + state.context_usage.last_compacted_at, + )); + signature +} + +pub(super) fn runtime_event_key(event: &AgentRuntimeEvent) -> String { + format!( + "{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", + event.agent_id, + event.task_id, + event.session_id, + event.run_id, + event.event_type, + event.action_id.as_deref().unwrap_or_default(), + event.status, + event.phase, + event.updated_at, + event.summary, + event.detail.as_deref().unwrap_or_default() + ) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/report.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/report.rs new file mode 100644 index 000000000..e2469b0f9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/report.rs @@ -0,0 +1,149 @@ +use super::*; + +pub(super) const SWARM_TURN_REPORT_PREFIX: &str = "[turn.report] "; +pub(super) const SWARM_TURN_REPORT_SCHEMA_VERSION: &str = "game-creator-swarm-turn-report.v1"; +pub(super) const SWARM_TURN_FAILED_ERROR: &str = "swarm-turn-failed"; +pub(super) const SWARM_TURN_INCOMPLETE_ERROR: &str = "swarm-turn-incomplete"; +pub(super) const SWARM_TURN_RECONCILIATION_ERROR: &str = "swarm-turn-needs-reconciliation"; + +#[derive(Debug, Eq, PartialEq)] +pub(super) enum SwarmTurnOutcome { + Settled(SwarmTurnReport), + Failed { + agent_ids: Vec, + report: SwarmTurnReport, + }, + Incomplete { + reasons: Vec, + report: SwarmTurnReport, + }, + NeedsReconciliation { + agent_ids: Vec, + report: SwarmTurnReport, + }, + Quit, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum SwarmTurnReportOutcome { + Settled, + Failed, + Incomplete, + NeedsReconciliation, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SwarmTurnReport { + pub(super) schema_version: &'static str, + pub(super) outcome: SwarmTurnReportOutcome, + pub(super) parent_agent_id: String, + pub(super) session_id: String, + pub(super) parent_run_id: Option, + pub(super) runtime_count: usize, + pub(super) busy_runtime_count: usize, + pub(super) pending_task_count: u64, + pub(super) running_task_count: u64, + pub(super) waiting_for_confirmation_count: u64, + pub(super) waiting_for_user_input_count: u64, + pub(super) new_assistant_message_count: usize, + pub(super) final_reply_chars: usize, + pub(super) reconciliation_agent_count: usize, +} + +pub(super) fn build_swarm_turn_report( + outcome: SwarmTurnReportOutcome, + parent_agent_id: &str, + session_id: &str, + runtimes: &[AgentRuntimeResult], + conversation_metrics: SwarmTurnConversationMetrics, + reconciliation_agent_count: usize, +) -> SwarmTurnReport { + let parent_run_id = runtimes + .iter() + .find(|runtime| { + runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id + }) + .map(|runtime| runtime.state.run_id.trim()) + .filter(|run_id| !run_id.is_empty()) + .map(str::to_string); + SwarmTurnReport { + schema_version: SWARM_TURN_REPORT_SCHEMA_VERSION, + outcome, + parent_agent_id: parent_agent_id.to_string(), + session_id: session_id.to_string(), + parent_run_id, + runtime_count: runtimes.len(), + busy_runtime_count: runtimes + .iter() + .filter(|runtime| runtime_is_busy(runtime)) + .count(), + pending_task_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.pending)) + .sum(), + running_task_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.running)) + .sum(), + waiting_for_confirmation_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.waiting_for_confirmation)) + .sum(), + waiting_for_user_input_count: runtimes + .iter() + .map(|runtime| u64::from(runtime.task_queue.waiting_for_user_input)) + .sum(), + new_assistant_message_count: conversation_metrics.new_assistant_message_count, + final_reply_chars: conversation_metrics.final_reply_chars, + reconciliation_agent_count, + } +} + +pub(super) fn print_turn_outcome( + outcome: SwarmTurnOutcome, + output: &mut W, +) -> Result<(), String> { + match outcome { + SwarmTurnOutcome::Settled(report) => print_swarm_turn_report(&report, output), + SwarmTurnOutcome::Failed { agent_ids, report } => { + writeln!( + output, + "[已失败] 以下 Runtime 到达失败终态:{}", + agent_ids.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_turn_report(&report, output) + } + SwarmTurnOutcome::Incomplete { reasons, report } => { + writeln!( + output, + "[未完成] 当前 turn 未满足可信终态:{}", + reasons.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_turn_report(&report, output) + } + SwarmTurnOutcome::NeedsReconciliation { agent_ids, report } => { + writeln!( + output, + "[已阻断] 以下 Agent 需要人工 reconciliation:{}", + agent_ids.join(", ") + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + print_swarm_turn_report(&report, output) + } + SwarmTurnOutcome::Quit => Ok(()), + } +} + +pub(super) fn print_swarm_turn_report( + report: &SwarmTurnReport, + output: &mut W, +) -> Result<(), String> { + let json = serde_json::to_string(report) + .map_err(|error| format!("序列化 turn report 失败:{error}"))?; + writeln!(output, "{SWARM_TURN_REPORT_PREFIX}{json}") + .map_err(|error| format!("写入终端失败:{error}")) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs new file mode 100644 index 000000000..dc5a6b632 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/terminal_classification.rs @@ -0,0 +1,440 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SwarmTurnTerminalClassification { + Settled, + Failed, + Incomplete, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SwarmSpecialistFailureDisposition { + Recoverable, + Failed, + Incomplete, +} + +#[derive(Default)] +pub(super) struct SwarmTerminalFailureScan { + pub(super) failed_agents: Vec, + pub(super) incomplete_reasons: Vec, + pub(super) reconciliation_agents: Vec, +} + +pub(super) fn swarm_parent_runtime<'a>( + parent_agent_id: &str, + session_id: &str, + runtimes: &'a [AgentRuntimeResult], +) -> Option<&'a AgentRuntimeResult> { + runtimes.iter().find(|runtime| { + runtime.state.agent_id == parent_agent_id && runtime.state.session_id == session_id + }) +} + +pub(super) fn runtime_terminal_failure_kind(runtime: &AgentRuntimeResult) -> Option<&'static str> { + if runtime.state.phase == "needs-reconciliation" { + None + } else if runtime.state.phase == "budget-exhausted" { + Some("budget-exhausted") + } else if runtime.state.status == "cancelled" || runtime.state.phase == "cancelled" { + Some("cancelled") + } else if runtime.state.status == "failed" { + Some("failed") + } else { + None + } +} + +pub(super) fn parent_runtime_is_active(runtime: &AgentRuntimeResult) -> bool { + runtime.state.phase != "needs-reconciliation" + && (matches!( + runtime.state.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) || runtime.recent_tasks.iter().any(|task| { + task.run_id == runtime.state.run_id + && matches!( + task.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) + })) +} + +pub(super) fn static_delegate_delivery_has_repairable_contract( + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + delivery.repair_of_delegation_id.is_none() + && delivery.status != StaticDelegateDeliveryStatus::Suppressed + && (!delivery.acceptance_criteria.is_empty() || !delivery.expected_artifacts.is_empty()) + && delivery.structured_result.as_ref().is_none_or(|result| { + result.contract_status == StaticDelegateContractStatus::NeedsRepair + }) +} + +pub(super) fn classify_failed_specialist( + parent: &AgentRuntimeResult, + child: &AgentRuntimeResult, + delivery: Option<&StaticDelegateDeliveryRecord>, + successful_repair: bool, +) -> SwarmSpecialistFailureDisposition { + let delivery_matches = delivery.is_some_and(|delivery| { + child.state.source == "agent-delegate" + && child.state.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) + && child.state.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) + && child.state.delegation_id.as_deref() == Some(delivery.delegation_id.as_str()) + && delivery.parent_agent_id == parent.state.agent_id + && delivery.parent_session_id == parent.state.session_id + && delivery.parent_run_id == parent.state.run_id + && delivery.target_agent_id == child.state.agent_id + && delivery.target_session_id == child.state.session_id + && delivery.target_run_id == child.state.run_id + }); + if !delivery_matches { + return SwarmSpecialistFailureDisposition::Failed; + } + let delivery = delivery.expect("matching delivery exists"); + if !static_delegate_delivery_has_repairable_contract(delivery) { + return SwarmSpecialistFailureDisposition::Failed; + } + if successful_repair { + return SwarmSpecialistFailureDisposition::Recoverable; + } + if parent_runtime_is_active(parent) { + SwarmSpecialistFailureDisposition::Recoverable + } else { + SwarmSpecialistFailureDisposition::Incomplete + } +} + +pub(super) fn original_delivery_has_successful_repair( + original: &StaticDelegateDeliveryRecord, + claimed_deliveries: &[StaticDelegateDeliveryRecord], +) -> bool { + original.repair_of_delegation_id.is_none() + && claimed_deliveries.iter().any(|candidate| { + candidate.repair_of_delegation_id.as_deref() == Some(original.delegation_id.as_str()) + && candidate.terminal_status.as_deref() == Some("completed") + && candidate.structured_result.as_ref().is_some_and(|result| { + result.contract_status == StaticDelegateContractStatus::EvidenceReady + }) + }) +} + +pub(super) fn scan_swarm_terminal_failures_at( + root: &Path, + parent_agent_id: &str, + session_id: &str, + runtimes: &[AgentRuntimeResult], +) -> SwarmTerminalFailureScan { + let mut scan = SwarmTerminalFailureScan::default(); + let Some(parent) = swarm_parent_runtime(parent_agent_id, session_id, runtimes) else { + return scan; + }; + let claimed_deliveries = match claimed_static_delegate_deliveries_at( + root, + &parent.state.agent_id, + &parent.state.run_id, + ) { + Ok(deliveries) => deliveries, + Err(_) => { + scan.reconciliation_agents + .push(parent.state.agent_id.clone()); + return scan; + } + }; + if let Some(kind) = runtime_terminal_failure_kind(parent) { + scan.failed_agents + .push(format!("{}:{kind}", parent.state.agent_id)); + } + for child in runtimes.iter().filter(|runtime| { + runtime.state.source == "agent-delegate" + && runtime.state.parent_agent_id.as_deref() == Some(parent.state.agent_id.as_str()) + && runtime.state.parent_run_id.as_deref() == Some(parent.state.run_id.as_str()) + && runtime_terminal_failure_kind(runtime).is_some() + }) { + let Some(delegation_id) = child + .state + .delegation_id + .as_deref() + .filter(|value| !value.is_empty()) + else { + scan.failed_agents.push(format!( + "{}:{}", + child.state.agent_id, + runtime_terminal_failure_kind(child).unwrap_or("failed") + )); + continue; + }; + let delivery = match read_static_delegate_delivery_at(root, delegation_id) { + Ok(Some(delivery)) => delivery, + Ok(None) | Err(_) => { + scan.reconciliation_agents + .push(child.state.agent_id.clone()); + continue; + } + }; + let successful_repair = + original_delivery_has_successful_repair(&delivery, &claimed_deliveries); + match classify_failed_specialist(parent, child, Some(&delivery), successful_repair) { + SwarmSpecialistFailureDisposition::Recoverable => {} + SwarmSpecialistFailureDisposition::Failed => scan.failed_agents.push(format!( + "{}:{}", + child.state.agent_id, + runtime_terminal_failure_kind(child).unwrap_or("failed") + )), + SwarmSpecialistFailureDisposition::Incomplete => scan + .incomplete_reasons + .push(format!("repair-required:{}", child.state.agent_id)), + } + } + scan.failed_agents.sort(); + scan.failed_agents.dedup(); + scan.incomplete_reasons.sort(); + scan.incomplete_reasons.dedup(); + scan.reconciliation_agents.sort(); + scan.reconciliation_agents.dedup(); + scan +} + +pub(super) fn swarm_unhandled_interaction_reasons( + parent_agent_id: &str, + runtimes: &[AgentRuntimeResult], + input_closed: bool, +) -> Vec { + let mut reasons = Vec::new(); + for runtime in runtimes { + let waiting_for_confirmation = runtime.state.status == "waiting-for-confirmation" + || runtime.state.pending_tool_action.is_some() + || runtime.task_queue.waiting_for_confirmation > 0; + let waiting_for_user_input = runtime.state.status == "waiting-for-user-input" + || runtime.user_input_request.is_some() + || runtime.task_queue.waiting_for_user_input > 0; + if input_closed && waiting_for_confirmation { + reasons.push(format!("pending-confirmation:{}", runtime.state.agent_id)); + } + if waiting_for_user_input && (input_closed || runtime.state.agent_id != parent_agent_id) { + reasons.push(format!("pending-user-input:{}", runtime.state.agent_id)); + } + } + reasons.sort(); + reasons.dedup(); + reasons +} + +pub(super) fn parent_runtime_completed(runtime: &AgentRuntimeResult) -> bool { + runtime.state.phase == "completed" + && matches!(runtime.state.status.as_str(), "idle" | "completed") +} + +pub(super) fn classify_swarm_turn_terminal( + parent: Option<&AgentRuntimeResult>, + conversation_metrics: SwarmTurnConversationMetrics, + failed_runtime_count: usize, + pending_interaction_count: usize, + completion_blocker_count: usize, +) -> SwarmTurnTerminalClassification { + if failed_runtime_count > 0 + || parent.is_some_and(|runtime| runtime_terminal_failure_kind(runtime).is_some()) + { + return SwarmTurnTerminalClassification::Failed; + } + if parent.is_none_or(|runtime| !parent_runtime_completed(runtime)) + || pending_interaction_count > 0 + || completion_blocker_count > 0 + || conversation_metrics.new_assistant_message_count != 1 + || conversation_metrics.final_reply_chars == 0 + { + return SwarmTurnTerminalClassification::Incomplete; + } + SwarmTurnTerminalClassification::Settled +} + +pub(super) fn append_swarm_terminal_snapshot_reasons( + reasons: &mut Vec, + parent: Option<&AgentRuntimeResult>, + conversation_metrics: SwarmTurnConversationMetrics, +) { + match parent { + None => reasons.push("parent-runtime-missing".to_string()), + Some(parent) if !parent_runtime_completed(parent) => reasons.push(format!( + "parent-not-completed:{}:{}", + parent.state.status, parent.state.phase + )), + Some(_) => {} + } + if conversation_metrics.new_assistant_message_count != 1 { + reasons.push(format!( + "assistant-count={}", + conversation_metrics.new_assistant_message_count + )); + } else if conversation_metrics.final_reply_chars == 0 { + reasons.push("assistant-empty".to_string()); + } + reasons.sort(); + reasons.dedup(); +} + +pub(super) fn swarm_parent_completion_contract_blockers_at( + root: &Path, + parent: &AgentRuntimeResult, +) -> Vec { + let mut blockers = Vec::new(); + let agent_id = parent.state.agent_id.as_str(); + let run_id = parent.state.run_id.as_str(); + if let Some(blocker) = structured_plan_completion_blocker(&parent.state) { + blockers.push(blocker.tool); + } + if parent.state.goal_id.is_some() + && !matches!( + parent.state.goal_status.as_deref(), + Some(AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED) + ) + { + blockers.push("runtime.goal".to_string()); + } + if parent.state.pending_tool_action.is_some() { + blockers.push("runtime.pending_tool_action".to_string()); + } + match crate::provider_retry::read_for_run_at(root, agent_id, run_id) { + Ok(None) => {} + Ok(Some(_)) | Err(_) => blockers.push("runtime.provider_retry".to_string()), + } + let provider_action_batch_path = + game_creator_agent_runtime_provider_action_batch_path(root, agent_id, run_id); + if provider_action_batch_path.exists() + || agent_runtime_json_sidecar_backup_path(&provider_action_batch_path).exists() + { + blockers.push("runtime.provider_action_batch".to_string()); + } + let finalization_path = game_creator_agent_runtime_finalization_path(root, agent_id, run_id); + if finalization_path.exists() + || agent_runtime_json_sidecar_backup_path(&finalization_path).exists() + { + blockers.push("runtime.finalization".to_string()); + } + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { + Ok(resolution) => { + match read_supervisor_collaboration_state_at(root, agent_id, run_id) { + Ok(state) => { + if supervisor_collaboration_completion_gap(&resolution.policy, &state) + .is_some() + { + blockers.push("runtime.collaboration_policy".to_string()); + } + } + Err(_) => blockers.push("runtime.collaboration_policy".to_string()), + } + } + Err(_) => blockers.push("runtime.collaboration_policy".to_string()), + } + } + if let Some(blocker) = process_session_completion_blocker_at(root, agent_id, run_id) { + blockers.push(blocker.tool); + } + if let Some(blocker) = isolated_join_completion_blocker_at(root, agent_id, run_id) { + blockers.push(blocker.tool); + } + if let Some(blocker) = static_delegate_completion_blocker_at(root, agent_id, run_id) { + blockers.push(blocker.tool); + } + if let Some(blocker) = project_verification_completion_blocker_at(root, agent_id, run_id, &[]) { + blockers.push(blocker.tool); + } + blockers.sort(); + blockers.dedup(); + blockers +} + +pub(super) fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec { + runtimes + .iter() + .filter(|runtime| { + runtime.state.phase == "needs-reconciliation" + || (runtime.state.status == "waiting-for-confirmation" + && runtime.state.pending_tool_action.is_none()) + || (runtime.state.status == "waiting-for-user-input" + && runtime.user_input_request.is_none()) + }) + .map(|runtime| runtime.state.agent_id.clone()) + .collect() +} + +pub(super) fn build_reconciliation_turn_outcome( + root: &Path, + parent_agent_id: &str, + session_id: &str, + conversation_baseline: &SwarmTurnConversationBaseline, + runtimes: &[AgentRuntimeResult], + mut agent_ids: Vec, +) -> Result { + agent_ids.sort(); + agent_ids.dedup(); + let conversation_metrics = + read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + parent_agent_id, + session_id, + runtimes, + conversation_metrics, + agent_ids.len(), + ); + Ok(SwarmTurnOutcome::NeedsReconciliation { agent_ids, report }) +} + +pub(super) fn build_failed_turn_outcome( + root: &Path, + parent_agent_id: &str, + session_id: &str, + conversation_baseline: &SwarmTurnConversationBaseline, + runtimes: &[AgentRuntimeResult], + mut agent_ids: Vec, +) -> Result { + agent_ids.sort(); + agent_ids.dedup(); + let conversation_metrics = + read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Failed, + parent_agent_id, + session_id, + runtimes, + conversation_metrics, + 0, + ); + Ok(SwarmTurnOutcome::Failed { agent_ids, report }) +} + +pub(super) fn build_incomplete_turn_outcome( + root: &Path, + parent_agent_id: &str, + session_id: &str, + conversation_baseline: &SwarmTurnConversationBaseline, + runtimes: &[AgentRuntimeResult], + mut reasons: Vec, +) -> Result { + reasons.sort(); + reasons.dedup(); + if reasons.is_empty() { + reasons.push("terminal-contract-not-proven".to_string()); + } + let conversation_metrics = + read_turn_conversation_metrics(root, parent_agent_id, session_id, conversation_baseline)?; + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Incomplete, + parent_agent_id, + session_id, + runtimes, + conversation_metrics, + 0, + ); + Ok(SwarmTurnOutcome::Incomplete { reasons, report }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs new file mode 100644 index 000000000..270eebfc1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/tests.rs @@ -0,0 +1,1529 @@ +use super::*; + +fn runtime(status: &str, phase: &str, pending: u32) -> AgentRuntimeResult { + let state = serde_json::from_value::(serde_json::json!({ + "agentId": "code-prototype", + "runId": "run-test", + "status": status, + "phase": phase, + })) + .expect("deserialize runtime fixture"); + let mut task_queue = AgentRuntimeTaskQueueSummary::default(); + task_queue.pending = pending; + AgentRuntimeResult { + state, + accepted_run_id: None, + session_path: String::new(), + event_path: String::new(), + task_path: String::new(), + task_queue, + recent_events: Vec::new(), + recent_tasks: Vec::new(), + response_stream: None, + user_input_request: None, + } +} + +fn response_stream( + request_slot: &str, + response_revision: u64, + sequence: u64, + status: &str, + accumulated_text: &str, +) -> AgentRuntimeResponseStream { + AgentRuntimeResponseStream { + schema_version: "game-creator-runtime-response-stream.v1".to_string(), + agent_id: "code-prototype".to_string(), + task_id: "code-prototype".to_string(), + session_id: "session-test".to_string(), + run_id: "run-test".to_string(), + request_kind: "final-reply".to_string(), + request_slot: request_slot.to_string(), + applied_steer_cursor: 0, + response_revision, + sequence, + status: status.to_string(), + accumulated_text: accumulated_text.to_string(), + finish_reason: None, + started_at: 100, + updated_at: 100 + sequence, + } +} + +fn runtime_with_response_stream(stream: AgentRuntimeResponseStream) -> AgentRuntimeResult { + let mut snapshot = runtime("running", "response", 0); + snapshot.state.session_id = stream.session_id.clone(); + snapshot.response_stream = Some(stream); + snapshot +} + +#[test] +fn new_supervisor_runs_fix_cli_source_and_select_requested_profile() { + for run_profile in [ + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ] { + assert_eq!( + resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_profile,) + .expect("resolve supervisor launch"), + SwarmNewRunLaunch::ProjectSupervisor { + source: AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + run_profile, + } + ); + } +} + +#[test] +fn explicit_parent_debug_keeps_standard_profile_only() { + assert_eq!( + resolve_swarm_new_run_launch("code-prototype", AGENT_RUNTIME_RUN_PROFILE_STANDARD,) + .expect("resolve explicit parent debug launch"), + SwarmNewRunLaunch::ExplicitParentDebug, + ); + assert!(resolve_swarm_new_run_launch( + "code-prototype", + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + ) + .expect_err("autonomous profile must stay on the supervisor root run") + .contains(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)); + assert!( + resolve_swarm_new_run_launch(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "unsupported",) + .is_err() + ); +} + +#[test] +fn same_run_steer_preserves_bound_autonomous_profile() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-profile-steer-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-swarm-profile", "Swarm Profile Steer") + .expect("initialize profile steer project"); + let run_id = "swarm-profile-steer-run"; + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "生成一版可试玩项目", + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + "准备自主构建", + vec!["实现并验证最小可玩闭环".to_string()], + ) + .expect("start autonomous supervisor runtime"); + + let steered = steer_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "swarm-profile-steer-1", + "保持当前目标并补充触屏操作", + "swarm-cli", + ) + .expect("steer autonomous supervisor runtime"); + + assert_eq!(steered.runtime.state.run_id, run_id); + assert_eq!( + steered.runtime.state.run_profile, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + ); + assert_eq!( + steered.runtime.state.run_profile_binding_fingerprint, + binding.binding_fingerprint + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn parses_chat_commands_without_stealing_normal_messages() { + assert_eq!(parse_swarm_chat_input(" "), None); + assert_eq!( + parse_swarm_chat_input("/agents"), + Some(SwarmChatInput::Agents) + ); + assert_eq!(parse_swarm_chat_input("/exit"), Some(SwarmChatInput::Quit)); + assert_eq!( + parse_swarm_chat_input("/status"), + Some(SwarmChatInput::Status) + ); + assert_eq!( + parse_swarm_chat_input("/compact"), + Some(SwarmChatInput::Compact) + ); + assert_eq!(parse_swarm_chat_input("/mcp"), Some(SwarmChatInput::Mcp)); + assert_eq!( + parse_swarm_chat_input("让策划和程序并行检查玩法"), + Some(SwarmChatInput::Message( + "让策划和程序并行检查玩法".to_string() + )) + ); +} + +#[test] +fn parses_goal_commands_and_keeps_goal_namespace_out_of_messages() { + assert_eq!( + parse_swarm_chat_input("/goal"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)) + ); + assert_eq!( + parse_swarm_chat_input("/goal status"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Status)) + ); + assert_eq!( + parse_swarm_chat_input("/goal 完成可玩的战斗循环"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Start( + "完成可玩的战斗循环".to_string() + ))) + ); + assert_eq!( + parse_swarm_chat_input("/goal edit 增加键盘与触屏验收"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Edit( + "增加键盘与触屏验收".to_string() + ))) + ); + assert_eq!( + parse_swarm_chat_input("/goal pause"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Pause)) + ); + assert_eq!( + parse_swarm_chat_input("/goal resume"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Resume)) + ); + assert_eq!( + parse_swarm_chat_input("/goal clear"), + Some(SwarmChatInput::Goal(SwarmGoalCommand::Clear)) + ); + + for invalid in [ + "/goal-status", + "/goal/status", + "/goal edit", + "/goal pause now", + ] { + assert!(matches!( + parse_swarm_chat_input(invalid), + Some(SwarmChatInput::InvalidGoal(_)) + )); + assert!(!matches!( + parse_swarm_chat_input(invalid), + Some(SwarmChatInput::Message(_)) + )); + } +} + +#[test] +fn swarm_help_lists_the_complete_goal_control_surface() { + let mut output = Vec::new(); + print_swarm_chat_help(&mut output).expect("print swarm help"); + let output = String::from_utf8(output).expect("help output is utf-8"); + + for command in [ + "/status", + "/compact", + "/mcp", + "/goal <目标>", + "/goal status", + "/goal edit <目标>", + "/goal pause", + "/goal resume", + "/goal clear", + ] { + assert!(output.contains(command), "missing help command: {command}"); + } +} + +#[test] +fn swarm_mcp_catalog_is_bounded_and_omits_server_instructions() { + let instruction = "PRIVATE_MCP_SERVER_INSTRUCTIONS"; + let catalog = GameCreatorMcpCatalog { + fingerprint: "a".repeat(64), + servers: vec![GameCreatorMcpServerStatus { + server_id: "fixture".to_string(), + enabled: true, + required: false, + transport: "stdio".to_string(), + connected: true, + server_name: Some("fixture-server".to_string()), + server_version: Some("1.0.0".to_string()), + instructions: instruction.to_string(), + instructions_chars: instruction.chars().count(), + tool_count: 1, + error: None, + }], + tools: vec![GameCreatorMcpCatalogTool { + server_id: "fixture".to_string(), + name: "lookup".to_string(), + title: Some("Fixture lookup".to_string()), + description: format!("{} DESCRIPTION_TAIL_SENTINEL", "D".repeat(240)), + input_schema: serde_json::json!({ + "type": "object", + "description": format!("{} SCHEMA_TAIL_SENTINEL", "S".repeat(800)), + }), + output_schema: None, + read_only_hint: true, + destructive_hint: false, + open_world_hint: false, + configured_approval_mode: "auto".to_string(), + effective_approval_mode: "auto".to_string(), + fingerprint: "b".repeat(64), + }], + }; + let mut output = Vec::new(); + print_swarm_mcp_catalog(&catalog, &mut output).expect("print MCP catalog"); + let output = String::from_utf8(output).expect("MCP output is utf-8"); + + assert!(output.contains("catalog=aaaaaaaaaaaa servers=1 tools=1")); + assert!(output.contains("server=fixture transport=stdio")); + assert!(output.contains("tool=fixture/lookup approval=auto readOnly=true")); + assert!(!output.contains(instruction)); + assert!(!output.contains("DESCRIPTION_TAIL_SENTINEL")); + assert!(!output.contains("SCHEMA_TAIL_SENTINEL")); + assert!(output.len() < 1_200); +} + +#[test] +fn goal_status_prints_identity_outcome_and_completion_standard() { + let goal = AgentGoalRecord { + schema_version: AGENT_GOAL_SCHEMA_VERSION.to_string(), + project_id: "project-1".to_string(), + goal_id: "goal-1".to_string(), + agent_id: "project-supervisor".to_string(), + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + revision: 3, + status: AGENT_GOAL_STATUS_ACTIVE.to_string(), + outcome: "完成首个可玩版本".to_string(), + constraints: vec!["不新增平行 Runtime".to_string()], + verification: vec!["键盘与触屏均可完成一局".to_string()], + completion_evidence: Vec::new(), + response_fingerprint: None, + created_at: 1, + pause_requested_at: None, + paused_at: None, + completed_at: None, + cleared_at: None, + error: None, + updated_at: 2, + }; + let mut output = Vec::new(); + print_swarm_goal_status("session-1", Some(&goal), &mut output).expect("print goal status"); + let output = String::from_utf8(output).expect("goal output is utf-8"); + + assert!(output.contains("goal=goal-1 run=run-1 revision=3 status=active")); + assert!(output.contains("[Goal 目标] 完成首个可玩版本")); + assert!(output.contains("[Goal 约束] 不新增平行 Runtime")); + assert!(output.contains("[Goal 完成标准] 键盘与触屏均可完成一局")); +} + +#[test] +fn swarm_stays_busy_for_active_queue_and_reconciliation() { + assert!(runtimes_are_busy(&[runtime("running", "planning", 0)])); + assert!(runtimes_are_busy(&[runtime("idle", "completed", 1)])); + assert!(runtimes_are_busy(&[runtime( + "failed", + "needs-reconciliation", + 0 + )])); + assert!(!runtimes_are_busy(&[runtime("idle", "completed", 0)])); + assert!(!runtimes_are_busy(&[runtime("failed", "failed", 0)])); +} + +#[test] +fn active_turn_eof_closes_input_once_without_requesting_quit() { + let mut input_closed = false; + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + + mark_swarm_turn_input_closed(&mut input_closed, &mut observer, &mut output) + .expect("close active turn input"); + mark_swarm_turn_input_closed(&mut input_closed, &mut observer, &mut output) + .expect("repeat closed input is idempotent"); + + assert!(input_closed); + let output = String::from_utf8(output).expect("input close output is utf-8"); + assert_eq!(output.matches("[输入已关闭]").count(), 1); + assert!(output.contains("继续运行,等待可信终态")); + assert!(!output.contains("已退出 Agent Swarm Chat")); +} + +#[test] +fn active_turn_eof_keeps_observing_until_parent_completes() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-eof-active-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-swarm-eof", "Swarm EOF active turn") + .expect("initialize EOF project"); + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + let idle = default_game_creator_agent_runtime_state(role.task_id, "run-eof-idle"); + write_game_creator_agent_runtime_state(&root, &idle) + .expect("persist valid idle specialist state"); + } + } + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let before = append_local_conversation_message_at( + &root, + Some(parent_agent_id), + LocalConversationMessage { + role: "user".to_string(), + content: "继续完成当前项目".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append turn user message"); + let session_id = before.session_id.clone().expect("active parent session"); + let mut parent = runtime("running", "planning", 0).state; + parent.agent_id = parent_agent_id.to_string(); + parent.task_id = parent_agent_id.to_string(); + parent.session_id = session_id.clone(); + parent.run_id = "run-eof-active".to_string(); + parent.source = "agent-background-task".to_string(); + parent.current_task = "继续完成当前项目".to_string(); + write_game_creator_agent_runtime_state(&root, &parent).expect("persist active parent"); + let conversation_baseline = + new_swarm_turn_conversation_baseline(before.messages.len(), &parent.run_id); + + let completion_root = root.clone(); + let completion_session_id = session_id.clone(); + let completion = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(15)); + append_local_conversation_message_for_session_at( + &completion_root, + Some(parent_agent_id), + Some(&completion_session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "已完成可信终态".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append terminal assistant message"); + parent.status = "idle".to_string(); + parent.phase = "completed".to_string(); + parent.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(&completion_root, &parent) + .expect("persist completed parent"); + }); + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Eof).expect("send active turn EOF"); + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + + let outcome = wait_for_swarm_turn( + &root, + parent_agent_id, + &session_id, + conversation_baseline, + &rx, + &mut output, + &mut observer, + Duration::from_millis(2), + Duration::from_millis(8), + ) + .expect("observe active turn after EOF"); + completion.join().expect("join completion writer"); + + let output = String::from_utf8(output).expect("EOF turn output is utf-8"); + let runtime_diagnostics = read_game_creator_agent_runtimes_at(&root) + .expect("read terminal runtime diagnostics") + .into_iter() + .filter(|runtime| runtime.state.phase == "needs-reconciliation") + .map(|runtime| { + format!( + "{}:{}", + runtime.state.agent_id, + runtime.state.error.unwrap_or_default() + ) + }) + .collect::>(); + assert!( + matches!(outcome, SwarmTurnOutcome::Settled(_)), + "unexpected outcome: {outcome:?}; diagnostics={runtime_diagnostics:?}; output={output}" + ); + assert!(output.contains("[输入已关闭]")); + assert!(output.contains("已完成可信终态")); + assert!(!output.contains("已退出 Agent Swarm Chat")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn recovered_prebaseline_assistant_counts_once_without_duplicate_output() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-recovered-assistant-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at( + &root, + "project-swarm-recovered-assistant", + "Swarm recovered assistant", + ) + .expect("initialize recovered assistant project"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let conversation = append_local_conversation_message_at( + &root, + Some(parent_agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "已在恢复阶段持久化".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append recovered assistant"); + let session_id = conversation.session_id.expect("active parent session"); + let mut baseline = + new_swarm_turn_conversation_baseline(conversation.messages.len(), "run-recovered"); + baseline.recovered_assistant = Some(SwarmRecoveredAssistant { + run_id: "run-recovered".to_string(), + finalization_id: "finalization-recovered".to_string(), + message_id: "message-recovered".to_string(), + content: "已在恢复阶段持久化".to_string(), + }); + + let snapshot = read_turn_conversation_snapshot(&root, parent_agent_id, &session_id, &baseline) + .expect("read recovered conversation snapshot"); + assert_eq!(snapshot.metrics.new_assistant_message_count, 1); + assert_eq!( + snapshot.metrics.final_reply_chars, + "已在恢复阶段持久化".chars().count() + ); + assert_eq!(snapshot.final_reply.as_deref(), Some("已在恢复阶段持久化")); + assert!(snapshot.recovered_before_observation); + + let mut output = Vec::new(); + let mut observer = SwarmRuntimeObserver::default(); + let metrics = print_new_parent_reply( + &root, + parent_agent_id, + &session_id, + &baseline, + &mut output, + &mut observer, + ) + .expect("print recovered parent reply"); + assert_eq!(metrics, snapshot.metrics); + let output = String::from_utf8(output).expect("recovered output is utf-8"); + assert!(output.contains("父 Agent 回复已在恢复前持久化")); + assert!(!output.contains("已在恢复阶段持久化")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn recovered_assistant_cannot_overlap_a_new_terminal_reply() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-recovered-overlap-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at( + &root, + "project-swarm-recovered-overlap", + "Swarm recovered overlap", + ) + .expect("initialize recovered overlap project"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let before = + read_local_conversation_for_session_at(root.as_path(), Some(parent_agent_id), None) + .expect("read initial conversation"); + let session_id = before.session_id.expect("active parent session"); + append_local_conversation_message_for_session_at( + &root, + Some(parent_agent_id), + Some(&session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: "baseline 后的新回复".to_string(), + agent_id: Some(parent_agent_id.to_string()), + }, + ) + .expect("append new terminal reply"); + let mut baseline = new_swarm_turn_conversation_baseline(before.messages.len(), "run-overlap"); + baseline.recovered_assistant = Some(SwarmRecoveredAssistant { + run_id: "run-overlap".to_string(), + finalization_id: "finalization-overlap".to_string(), + message_id: "message-overlap".to_string(), + content: "恢复回复".to_string(), + }); + + let error = read_turn_conversation_snapshot(&root, parent_agent_id, &session_id, &baseline) + .expect_err("recovered and new assistant replies must not be double counted"); + assert!(error.contains("与 baseline 后的新回复重叠")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn confirmation_prompt_propagates_eof_as_closed_input() { + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Eof).expect("send eof"); + let mut output = Vec::new(); + + let decision = prompt_swarm_decision( + Path::new("."), + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &rx, + &mut output, + "confirm> ", + ) + .expect("EOF is a turn input state, not an error"); + + assert!(matches!(decision, SwarmPromptDecision::InputClosed)); +} + +#[test] +fn terminal_classifier_requires_completed_parent_unique_reply_and_clear_contract() { + let mut parent = runtime("idle", "completed", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + parent.state.session_id = "session-terminal".to_string(); + parent.state.run_id = "run-terminal".to_string(); + let unique_reply = SwarmTurnConversationMetrics { + new_assistant_message_count: 1, + final_reply_chars: 12, + }; + + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 0, 0), + SwarmTurnTerminalClassification::Settled + ); + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 1, 0), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), unique_reply, 0, 0, 1), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal( + Some(&parent), + SwarmTurnConversationMetrics::default(), + 0, + 0, + 0, + ), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal( + Some(&parent), + SwarmTurnConversationMetrics { + new_assistant_message_count: 2, + final_reply_chars: 12, + }, + 0, + 0, + 0, + ), + SwarmTurnTerminalClassification::Incomplete + ); + assert_eq!( + classify_swarm_turn_terminal(None, unique_reply, 0, 0, 0), + SwarmTurnTerminalClassification::Incomplete + ); +} + +#[test] +fn terminal_classifier_fails_parent_failure_cancel_and_budget_exhaustion() { + let metrics = SwarmTurnConversationMetrics { + new_assistant_message_count: 1, + final_reply_chars: 8, + }; + for (status, phase) in [ + ("failed", "failed"), + ("cancelled", "cancelled"), + ("failed", "budget-exhausted"), + ] { + let parent = runtime(status, phase, 0); + assert_eq!( + classify_swarm_turn_terminal(Some(&parent), metrics, 0, 0, 0), + SwarmTurnTerminalClassification::Failed, + "parent {status}/{phase} must fail closed" + ); + } + let completed = runtime("idle", "completed", 0); + assert_eq!( + classify_swarm_turn_terminal(Some(&completed), metrics, 1, 0, 0), + SwarmTurnTerminalClassification::Failed + ); +} + +#[test] +fn pending_interactions_never_form_a_settled_snapshot() { + let mut parent = runtime("waiting-for-user-input", "waiting-for-user-input", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + let mut child = runtime("waiting-for-user-input", "waiting-for-user-input", 0); + child.state.agent_id = "code-prototype".to_string(); + let mut confirmation = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); + confirmation.state.agent_id = "quality-review".to_string(); + + assert!(swarm_unhandled_interaction_reasons( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[parent.clone()], + false, + ) + .is_empty()); + let child_reasons = swarm_unhandled_interaction_reasons( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[child], + false, + ); + assert_eq!(child_reasons, vec!["pending-user-input:code-prototype"]); + let closed_reasons = swarm_unhandled_interaction_reasons( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[parent, confirmation], + true, + ); + assert!(closed_reasons + .iter() + .any(|reason| reason == "pending-user-input:project-supervisor")); + assert!(closed_reasons + .iter() + .any(|reason| reason == "pending-confirmation:quality-review")); +} + +#[test] +fn original_specialist_failure_is_recoverable_but_repair_failure_closes() { + let mut parent = runtime("running", "waiting-for-delegate-receipts", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + parent.state.session_id = "session-parent".to_string(); + parent.state.run_id = "run-parent".to_string(); + let mut child = runtime("failed", "failed", 0); + child.state.agent_id = "code-prototype".to_string(); + child.state.session_id = "session-child".to_string(); + child.state.run_id = "run-child".to_string(); + child.state.source = "agent-delegate".to_string(); + child.state.parent_agent_id = Some(parent.state.agent_id.clone()); + child.state.parent_run_id = Some(parent.state.run_id.clone()); + child.state.delegation_id = Some("delivery-original".to_string()); + let acceptance = vec!["交付可运行原型".to_string()]; + let original = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-original", + "delivery-original", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + None, + ); + + assert_eq!( + classify_failed_specialist(&parent, &child, Some(&original), false), + SwarmSpecialistFailureDisposition::Recoverable + ); + let mut completed_parent = parent.clone(); + completed_parent.state.status = "idle".to_string(); + completed_parent.state.phase = "completed".to_string(); + assert_eq!( + classify_failed_specialist(&completed_parent, &child, Some(&original), false), + SwarmSpecialistFailureDisposition::Incomplete + ); + + child.state.run_id = "run-repair".to_string(); + child.state.delegation_id = Some("delivery-repair".to_string()); + let repair = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-repair", + "delivery-repair", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + Some("delivery-original"), + ); + assert_eq!( + classify_failed_specialist(&parent, &child, Some(&repair), false), + SwarmSpecialistFailureDisposition::Failed + ); +} + +#[test] +fn observer_failure_scan_waits_for_original_repair_and_fails_repair_child() { + let root = std::env::temp_dir().join(format!( + "swarm-cli-repair-scan-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-swarm-repair", "Swarm repair scan") + .expect("initialize repair scan project"); + let mut parent = runtime("running", "waiting-for-delegate-receipts", 0); + parent.state.agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(); + parent.state.session_id = "session-parent".to_string(); + parent.state.run_id = "run-parent".to_string(); + let mut child = runtime("failed", "failed", 0); + child.state.agent_id = "code-prototype".to_string(); + child.state.session_id = "session-child".to_string(); + child.state.run_id = "run-child".to_string(); + child.state.source = "agent-delegate".to_string(); + child.state.parent_agent_id = Some(parent.state.agent_id.clone()); + child.state.parent_run_id = Some(parent.state.run_id.clone()); + child.state.delegation_id = Some("delivery-original".to_string()); + let acceptance = vec!["交付可运行原型".to_string()]; + let original = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-original", + "delivery-original", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + None, + ); + create_or_read_static_delegate_delivery_at(&root, &original) + .expect("persist original delivery"); + + let original_scan = scan_swarm_terminal_failures_at( + &root, + &parent.state.agent_id, + &parent.state.session_id, + &[parent.clone(), child.clone()], + ); + assert!(original_scan.failed_agents.is_empty()); + assert!(original_scan.incomplete_reasons.is_empty()); + assert!(original_scan.reconciliation_agents.is_empty()); + + child.state.run_id = "run-repair".to_string(); + child.state.delegation_id = Some("delivery-repair".to_string()); + let repair = new_static_delegate_delivery_with_contract( + &parent.state.agent_id, + &parent.state.session_id, + &parent.state.run_id, + "action-repair", + "delivery-repair", + &child.state.agent_id, + &child.state.session_id, + &child.state.run_id, + &acceptance, + &[], + Some("delivery-original"), + ); + create_or_read_static_delegate_delivery_at(&root, &repair).expect("persist repair delivery"); + let repair_scan = scan_swarm_terminal_failures_at( + &root, + &parent.state.agent_id, + &parent.state.session_id, + &[parent.clone(), child], + ); + assert_eq!(repair_scan.failed_agents, vec!["code-prototype:failed"]); + assert!(repair_scan.incomplete_reasons.is_empty()); + assert!(repair_scan.reconciliation_agents.is_empty()); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn missing_confirmation_sidecar_is_reported_as_reconciliation() { + let broken = runtime("waiting-for-confirmation", "waiting-for-confirmation", 0); + assert_eq!( + swarm_reconciliation_agents(&[broken]), + vec!["code-prototype".to_string()] + ); +} + +#[test] +fn turn_report_counts_runtime_and_conversation_snapshots() { + let mut parent = runtime("running", "response", 2); + parent.state.agent_id = "project-supervisor".to_string(); + parent.state.session_id = "session-report".to_string(); + parent.state.run_id = "run-parent".to_string(); + parent.task_queue.running = 1; + parent.task_queue.waiting_for_confirmation = 2; + + let mut child = runtime("idle", "completed", 3); + child.state.agent_id = "child-code".to_string(); + child.task_queue.waiting_for_user_input = 1; + + let mut idle = runtime("idle", "completed", 0); + idle.state.agent_id = "design-review".to_string(); + let runtimes = vec![parent, child, idle]; + let (conversation_metrics, final_reply) = summarize_new_assistant_messages([ + ("user", "请继续"), + ("assistant", "阶段回复"), + ("tool", "PRIVATE_OBSERVATION"), + ("assistant", "最终🙂"), + ]); + assert_eq!(final_reply, Some("最终🙂")); + + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + "project-supervisor", + "session-report", + &runtimes, + conversation_metrics, + 1, + ); + + assert_eq!(report.schema_version, SWARM_TURN_REPORT_SCHEMA_VERSION); + assert_eq!(report.outcome, SwarmTurnReportOutcome::NeedsReconciliation); + assert_eq!(report.parent_agent_id, "project-supervisor"); + assert_eq!(report.session_id, "session-report"); + assert_eq!(report.parent_run_id.as_deref(), Some("run-parent")); + assert_eq!(report.runtime_count, 3); + assert_eq!(report.busy_runtime_count, 2); + assert_eq!(report.pending_task_count, 5); + assert_eq!(report.running_task_count, 1); + assert_eq!(report.waiting_for_confirmation_count, 2); + assert_eq!(report.waiting_for_user_input_count, 1); + assert_eq!(report.new_assistant_message_count, 2); + assert_eq!(report.final_reply_chars, "最终🙂".chars().count()); + assert_eq!(report.reconciliation_agent_count, 1); +} + +#[test] +fn turn_report_json_is_single_line_and_omits_sensitive_bodies_and_paths() { + let sensitive_reply = concat!( + "PRIVATE_REPLY_BODY\n", + "/private/project/root ", + "prompt=DO_NOT_LEAK observation=DO_NOT_LEAK CREDENTIAL_SENTINEL" + ); + let (conversation_metrics, _) = + summarize_new_assistant_messages([("assistant", sensitive_reply)]); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "project-supervisor", + "session-safe", + &[], + conversation_metrics, + 0, + ); + let json = serde_json::to_string(&report).expect("serialize turn report"); + let value = serde_json::from_str::(&json).expect("parse turn report"); + let object = value.as_object().expect("turn report is an object"); + + assert_eq!(json.lines().count(), 1); + assert_eq!(object.len(), 14); + for key in [ + "schemaVersion", + "outcome", + "parentAgentId", + "sessionId", + "parentRunId", + "runtimeCount", + "busyRuntimeCount", + "pendingTaskCount", + "runningTaskCount", + "waitingForConfirmationCount", + "waitingForUserInputCount", + "newAssistantMessageCount", + "finalReplyChars", + "reconciliationAgentCount", + ] { + assert!(object.contains_key(key), "turn report omitted {key}"); + } + assert_eq!( + value["schemaVersion"], + serde_json::json!(SWARM_TURN_REPORT_SCHEMA_VERSION) + ); + assert_eq!(value["outcome"], serde_json::json!("settled")); + assert_eq!(value["parentRunId"], serde_json::Value::Null); + assert_eq!(value["newAssistantMessageCount"], serde_json::json!(1)); + assert_eq!( + value["finalReplyChars"], + serde_json::json!(sensitive_reply.chars().count()) + ); + for forbidden in [ + "PRIVATE_REPLY_BODY", + "/private/project/root", + "DO_NOT_LEAK", + "CREDENTIAL_SENTINEL", + ] { + assert!(!json.contains(forbidden), "report leaked {forbidden}"); + } +} + +#[test] +fn turn_outcome_prints_all_terminal_reports_but_not_quit() { + let metrics = SwarmTurnConversationMetrics { + new_assistant_message_count: 1, + final_reply_chars: 4, + }; + let settled_report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "project-supervisor", + "session-settled", + &[], + metrics, + 0, + ); + let mut settled_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::Settled(settled_report), + &mut settled_output, + ) + .expect("print settled report"); + let settled_output = String::from_utf8(settled_output).expect("settled output is utf-8"); + assert_eq!(settled_output.lines().count(), 1); + assert!(settled_output.starts_with(SWARM_TURN_REPORT_PREFIX)); + assert!(settled_output.contains("\"outcome\":\"settled\"")); + + let failed_report = build_swarm_turn_report( + SwarmTurnReportOutcome::Failed, + "project-supervisor", + "session-failed", + &[], + metrics, + 0, + ); + let mut failed_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::Failed { + agent_ids: vec!["project-supervisor:budget-exhausted".to_string()], + report: failed_report, + }, + &mut failed_output, + ) + .expect("print failed report"); + let failed_output = String::from_utf8(failed_output).expect("failed output is utf-8"); + assert!(failed_output.starts_with("[已失败]")); + assert!(failed_output.contains("\"outcome\":\"failed\"")); + + let incomplete_report = build_swarm_turn_report( + SwarmTurnReportOutcome::Incomplete, + "project-supervisor", + "session-incomplete", + &[], + metrics, + 0, + ); + let mut incomplete_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::Incomplete { + reasons: vec!["assistant-count=0".to_string()], + report: incomplete_report, + }, + &mut incomplete_output, + ) + .expect("print incomplete report"); + let incomplete_output = + String::from_utf8(incomplete_output).expect("incomplete output is utf-8"); + assert!(incomplete_output.starts_with("[未完成]")); + assert!(incomplete_output.contains("\"outcome\":\"incomplete\"")); + + let reconciliation_report = build_swarm_turn_report( + SwarmTurnReportOutcome::NeedsReconciliation, + "project-supervisor", + "session-reconciliation", + &[], + metrics, + 2, + ); + let mut reconciliation_output = Vec::new(); + print_turn_outcome( + SwarmTurnOutcome::NeedsReconciliation { + agent_ids: vec!["code-prototype".to_string(), "external-runner".to_string()], + report: reconciliation_report, + }, + &mut reconciliation_output, + ) + .expect("print reconciliation report"); + let reconciliation_output = + String::from_utf8(reconciliation_output).expect("reconciliation output is utf-8"); + let lines = reconciliation_output.lines().collect::>(); + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0], + "[已阻断] 以下 Agent 需要人工 reconciliation:code-prototype, external-runner" + ); + assert!(lines[1].starts_with(SWARM_TURN_REPORT_PREFIX)); + assert!(lines[1].contains("\"outcome\":\"needs-reconciliation\"")); + assert!(lines[1].contains("\"reconciliationAgentCount\":2")); + + let mut quit_output = Vec::new(); + print_turn_outcome(SwarmTurnOutcome::Quit, &mut quit_output).expect("ignore quit"); + assert!(quit_output.is_empty()); +} + +#[test] +fn blank_agent_snapshots_do_not_reset_the_settle_window() { + let mut blank = runtime("idle", "idle", 0); + blank.state.run_id.clear(); + blank.state.updated_at = 100; + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + assert!(!observer + .print_changes(&[blank.clone()], &mut output) + .expect("observe first blank snapshot")); + blank.state.updated_at = 101; + assert!(!observer + .print_changes(&[blank], &mut output) + .expect("observe refreshed blank snapshot")); + assert!(output.is_empty()); +} + +#[test] +fn runtime_plan_revision_and_current_step_change_state_signature() { + let mut snapshot = runtime("running", "planning", 0); + snapshot.state.updated_at = 100; + snapshot.task_queue.updated_at = 100; + snapshot.state.plan_revision = 1; + snapshot.state.plan_steps = vec![ + AgentRuntimePlanStep { + index: 0, + title: "读取现有 CLI".to_string(), + status: "in_progress".to_string(), + detail: None, + updated_at: 100, + }, + AgentRuntimePlanStep { + index: 1, + title: "补充计划展示".to_string(), + status: "pending".to_string(), + detail: None, + updated_at: 100, + }, + ]; + snapshot.state.active_plan_step_index = Some(0); + + let initial = runtime_state_signature(&snapshot.state, &snapshot.task_queue); + snapshot.state.plan_revision = 2; + let revised = runtime_state_signature(&snapshot.state, &snapshot.task_queue); + assert_ne!(initial, revised); + + snapshot.state.plan_steps[0].title = "核对现有 CLI".to_string(); + let current_step_changed = runtime_state_signature(&snapshot.state, &snapshot.task_queue); + assert_ne!(revised, current_step_changed); + + snapshot.state.plan_steps[0].status = "completed".to_string(); + snapshot.state.plan_steps[1].status = "in_progress".to_string(); + snapshot.state.active_plan_step_index = Some(1); + let advanced = runtime_state_signature(&snapshot.state, &snapshot.task_queue); + assert_ne!(current_step_changed, advanced); +} + +#[test] +fn runtime_plan_output_is_bounded_and_omits_private_observations() { + let mut snapshot = runtime("running", "planning", 0); + snapshot.state.plan_revision = 7; + snapshot.state.plan_explanation = "已完成读取,进入验证".to_string(); + snapshot.state.current_action = "展示持久计划".to_string(); + snapshot.state.waiting_on = "开发者确认".to_string(); + snapshot.state.next_step = "运行 focused cargo test".to_string(); + snapshot.state.observations = vec![ + "PRIVATE_OBSERVATION_SENTINEL".to_string(), + "PRIVATE_DETAIL_SENTINEL".to_string(), + ]; + snapshot.state.plan_steps = (0..10) + .map(|index| AgentRuntimePlanStep { + index, + title: format!("计划步骤 {}", index + 1), + status: match index { + 0 | 1 => "completed", + 2 => "in_progress", + _ => "pending", + } + .to_string(), + detail: Some(format!("PRIVATE_STEP_DETAIL_{index}")), + updated_at: 100, + }) + .collect(); + snapshot.state.active_plan_step_index = Some(2); + + let mut output = Vec::new(); + print_runtime_state(&snapshot.state, &snapshot.task_queue, &mut output) + .expect("print runtime plan progress"); + let output = String::from_utf8(output).expect("runtime output is utf-8"); + + assert!(output.contains( + "[计划] revision=7 completed=2/10 current=#3 [in_progress] 计划步骤 3 | waiting=开发者确认 | next=运行 focused cargo test" + )); + assert!(output.contains("[计划说明] 已完成读取,进入验证")); + assert_eq!(output.matches("[计划步骤]").count(), 8); + assert!(output.contains("[计划步骤] #8 [pending] 计划步骤 8")); + assert!(output.contains("另有 2 条步骤未显示")); + assert!(!output.contains("计划步骤 9")); + assert!(!output.contains("PRIVATE_OBSERVATION_SENTINEL")); + assert!(!output.contains("PRIVATE_DETAIL_SENTINEL")); + assert!(!output.contains("PRIVATE_STEP_DETAIL")); +} + +#[test] +fn response_stream_prints_only_monotonic_utf8_suffixes() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 7, + 0, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "", + )); + + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe empty response stream")); + snapshot.response_stream = Some(response_stream( + "slot-1", + 7, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "你", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe first utf-8 suffix")); + snapshot.response_stream = Some(response_stream( + "slot-1", + 7, + 2, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "你好🙂", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe second utf-8 suffix")); + assert!(!observer + .print_changes(&[snapshot], &mut output) + .expect("ignore duplicate snapshot")); + observer + .close_response_line(&mut output) + .expect("close response line"); + + let output = String::from_utf8(output).expect("stream output is utf-8"); + assert!(output.contains("Agent[code-prototype]> 你好🙂")); + assert_eq!(output.matches("Agent[code-prototype]>").count(), 1); + assert!(!output.contains("你你好")); +} + +#[test] +fn response_stream_resets_for_non_prefix_and_new_request_slot() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 9, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "旧稿", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe initial stream"); + + snapshot.response_stream = Some(response_stream( + "slot-1", + 9, + 2, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "修正版", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe non-prefix correction"); + snapshot.response_stream = Some(response_stream( + "slot-2", + 9, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "最终版", + )); + observer + .print_changes(&[snapshot], &mut output) + .expect("observe new request slot"); + observer + .close_response_line(&mut output) + .expect("close response line"); + + let output = String::from_utf8(output).expect("stream output is utf-8"); + assert!(output.contains("reason=non-prefix-correction")); + assert!(output.contains("reason=new-request-slot")); + assert_eq!(output.matches("旧稿").count(), 1); + assert_eq!(output.matches("修正版").count(), 1); + assert_eq!(output.matches("最终版").count(), 1); +} + +#[test] +fn response_stream_resets_sequence_for_same_run_steer_cursor() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut initial = response_stream( + "slot-1", + 9, + 4, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "纠偏前回复", + ); + initial.applied_steer_cursor = 1; + observer + .print_changes(&[runtime_with_response_stream(initial)], &mut output) + .expect("observe pre-steer stream"); + + let mut steered = response_stream( + "slot-1", + 9, + 1, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "纠偏后回复", + ); + steered.applied_steer_cursor = 2; + observer + .print_changes(&[runtime_with_response_stream(steered)], &mut output) + .expect("observe same-run stream after steer"); + observer + .close_response_line(&mut output) + .expect("close steered response line"); + + let output = String::from_utf8(output).expect("steer output is utf-8"); + assert!(output.contains("reason=new-steer-cursor")); + assert!(!output.contains("reason=sequence-rollback")); + assert_eq!(output.matches("纠偏前回复").count(), 1); + assert_eq!(output.matches("纠偏后回复").count(), 1); +} + +#[test] +fn response_stream_reconnects_without_repeating_body_and_rejects_sequence_rollback() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 11, + 3, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "已输出", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe initial stream"); + + snapshot.response_stream = None; + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe disconnect")); + snapshot.response_stream = Some(response_stream( + "slot-1", + 11, + 3, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "已输出", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe reconnect")); + + snapshot.response_stream = Some(response_stream( + "slot-1", + 11, + 2, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_STREAMING, + "回退正文", + )); + assert!(observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("report sequence rollback")); + assert!(!observer + .print_changes(&[snapshot], &mut output) + .expect("deduplicate repeated rollback")); + + let cursor = observer + .response_streams + .get("code-prototype") + .expect("response cursor"); + assert_eq!(cursor.sequence, 3); + assert_eq!(cursor.accumulated_text, "已输出"); + assert_eq!(cursor.printed_accumulated_text.as_deref(), Some("已输出")); + + let recovered = runtime_with_response_stream(response_stream( + "slot-1", + 11, + 4, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "已输出继续", + )); + assert!(observer + .print_changes(&[recovered], &mut output) + .expect("resume from accepted high-water mark")); + observer + .close_response_line(&mut output) + .expect("close recovered response line"); + + let output = String::from_utf8(output).expect("stream output is utf-8"); + assert!(output.contains("reason=reconnect")); + assert!(output.contains("reason=sequence-rollback")); + assert_eq!(output.matches("已输出").count(), 1); + assert_eq!(output.matches("继续").count(), 1); + assert!(!output.contains("回退正文")); +} + +#[test] +fn settled_parent_reply_is_not_repeated_after_complete_stream() { + let mut observer = SwarmRuntimeObserver::default(); + let mut output = Vec::new(); + let mut snapshot = runtime_with_response_stream(response_stream( + "slot-1", + 13, + 4, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + "权威最终回复", + )); + observer + .print_changes(&[snapshot.clone()], &mut output) + .expect("observe complete stream"); + snapshot.response_stream = Some(response_stream( + "slot-1", + 13, + 5, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED, + "权威最终回复", + )); + observer + .print_changes(&[snapshot], &mut output) + .expect("observe committed stream without printing body"); + observer + .close_response_line(&mut output) + .expect("close response line"); + print_settled_parent_reply( + "code-prototype", + "session-test", + Some("权威最终回复"), + &observer, + &mut output, + ) + .expect("settle streamed reply"); + let (conversation_metrics, _) = + summarize_new_assistant_messages([("assistant", "权威最终回复")]); + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + "code-prototype", + "session-test", + &[], + conversation_metrics, + 0, + ); + print_turn_outcome(SwarmTurnOutcome::Settled(report), &mut output) + .expect("print settled report after stream"); + + let output = String::from_utf8(output).expect("settle output is utf-8"); + assert_eq!(output.matches("权威最终回复").count(), 1); + assert!(output.contains("父 Agent 回复已完整流式输出")); + assert!(output.contains(SWARM_TURN_REPORT_PREFIX)); + + let mut fallback = Vec::new(); + print_settled_parent_reply( + "code-prototype", + "session-test", + Some("未流过的权威回复"), + &SwarmRuntimeObserver::default(), + &mut fallback, + ) + .expect("print authoritative fallback"); + let fallback = String::from_utf8(fallback).expect("fallback output is utf-8"); + assert!(fallback.contains("Agent> 未流过的权威回复")); +} + +#[test] +fn response_stream_status_reports_only_status_sequence_and_char_count() { + let body = "PRIVATE_RESPONSE_BODY"; + let stream = response_stream( + "slot-private", + 17, + 8, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY, + body, + ); + let mut output = Vec::new(); + print_runtime_response_stream_status(Some(&stream), &mut output) + .expect("print response stream status"); + let output = String::from_utf8(output).expect("status output is utf-8"); + + assert_eq!( + output.trim(), + format!( + "[回复流] status=ready sequence=8 chars={}", + body.chars().count() + ) + ); + assert!(!output.contains(body)); + assert!(!output.contains("slot-private")); +} + +#[test] +fn input_channel_preserves_lines_and_eof() { + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Line("hello swarm".to_string())) + .expect("send line"); + tx.send(SwarmInputEvent::Eof).expect("send eof"); + assert_eq!( + receive_swarm_chat_line(&rx).expect("read line").as_deref(), + Some("hello swarm") + ); + assert_eq!(receive_swarm_chat_line(&rx).expect("read eof"), None); +} + +#[test] +fn confirmation_prompt_defers_to_bare_goal_status_without_deciding_action() { + let root = std::env::temp_dir().join(format!( + "swarm-goal-confirmation-{}-{}", + std::process::id(), + unix_millis() + )); + init_local_game_project_at(&root, "project-1", "Goal 确认提示测试") + .expect("initialize Goal prompt project"); + let (tx, rx) = mpsc::channel(); + tx.send(SwarmInputEvent::Line("/goal".to_string())) + .expect("send bare Goal status"); + let mut output = Vec::new(); + + let decision = prompt_swarm_decision( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &rx, + &mut output, + "", + ) + .expect("handle Goal status during confirmation"); + assert!(matches!(decision, SwarmPromptDecision::Deferred)); + let output = String::from_utf8(output).expect("prompt output is utf-8"); + assert!(output.contains("当前尚未设置持久目标")); + assert!(!output.contains("[已批准]")); + assert!(!output.contains("[已拒绝]")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn event_deduplication_keeps_phase_and_detail_changes() { + let base = serde_json::json!({ + "agentId": "code-prototype", + "taskId": "task-1", + "sessionId": "session-1", + "runId": "run-1", + "eventType": "observation", + "status": "running", + "phase": "action", + "summary": "工具观察", + "detail": "第一条", + "updatedAt": 100, + }); + let first = + serde_json::from_value::(base.clone()).expect("deserialize first event"); + let mut changed = base; + changed["phase"] = serde_json::json!("observation"); + changed["detail"] = serde_json::json!("第二条"); + let second = + serde_json::from_value::(changed).expect("deserialize second event"); + assert_ne!(runtime_event_key(&first), runtime_event_key(&second)); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs new file mode 100644 index 000000000..7d1fa7b17 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/swarm_cli/turn_wait.rs @@ -0,0 +1,347 @@ +use super::*; + +pub(super) const SWARM_CHAT_POLL_INTERVAL: Duration = Duration::from_millis(250); +pub(super) const SWARM_CHAT_SETTLE_WINDOW: Duration = Duration::from_millis(1_500); + +#[derive(Debug, Eq, PartialEq)] +pub(super) struct SwarmTurnObservation { + pub(super) outcome: SwarmTurnOutcome, + pub(super) input_closed: bool, +} + +pub(super) fn wait_for_swarm_turn( + root: &Path, + parent_agent_id: &str, + session_id: &str, + conversation_baseline: SwarmTurnConversationBaseline, + input: &Receiver, + output: &mut W, + observer: &mut SwarmRuntimeObserver, + poll_interval: Duration, + settle_window: Duration, +) -> Result { + let mut stable_since: Option = None; + let mut recovery_scan_required = true; + let mut last_runner_check = Instant::now(); + let mut input_closed = false; + loop { + let runtimes = read_game_creator_agent_runtimes_at(root)?; + let changed = observer.print_changes(&runtimes, output)?; + if changed { + stable_since = None; + } + let mut reconciliation = swarm_reconciliation_agents(&runtimes); + if !reconciliation.is_empty() { + observer.close_response_line(output)?; + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + reconciliation, + ); + } + if !input_closed { + match observer.resolve_confirmations(root, parent_agent_id, &runtimes, input, output)? { + SwarmConfirmationResolution::Handled => { + stable_since = None; + recovery_scan_required = true; + continue; + } + SwarmConfirmationResolution::InputClosed => { + mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; + stable_since = None; + continue; + } + SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), + SwarmConfirmationResolution::None => {} + } + match observer.resolve_user_input_requests( + root, + parent_agent_id, + &runtimes, + input, + output, + )? { + SwarmConfirmationResolution::Handled => { + stable_since = None; + recovery_scan_required = true; + continue; + } + SwarmConfirmationResolution::InputClosed => { + mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; + stable_since = None; + continue; + } + SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit), + SwarmConfirmationResolution::None => {} + } + } + let pending_interactions = + swarm_unhandled_interaction_reasons(parent_agent_id, &runtimes, input_closed); + if !pending_interactions.is_empty() { + observer.close_response_line(output)?; + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + pending_interactions, + ); + } + let failure_scan = + scan_swarm_terminal_failures_at(root, parent_agent_id, session_id, &runtimes); + if !failure_scan.reconciliation_agents.is_empty() { + observer.close_response_line(output)?; + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + failure_scan.reconciliation_agents, + ); + } + if !failure_scan.failed_agents.is_empty() { + observer.close_response_line(output)?; + return build_failed_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + failure_scan.failed_agents, + ); + } + if !failure_scan.incomplete_reasons.is_empty() { + observer.close_response_line(output)?; + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + failure_scan.incomplete_reasons, + ); + } + if last_runner_check.elapsed() >= Duration::from_secs(2) { + let runner = read_external_agent_runner_status(); + last_runner_check = Instant::now(); + if runtimes_are_busy(&runtimes) && (!runner.enabled || !runner.running) { + reconciliation.push("external-runner".to_string()); + observer.close_response_line(output)?; + return build_reconciliation_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + reconciliation, + ); + } + } + if runtimes_are_busy(&runtimes) { + stable_since = None; + recovery_scan_required = true; + } else { + let since = stable_since.get_or_insert_with(Instant::now); + if since.elapsed() >= settle_window { + if recovery_scan_required { + observer.close_response_line(output)?; + writeln!( + output, + "[收束] Runtime 已空闲,检查待发布的 receipt / join。" + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + output + .flush() + .map_err(|error| format!("刷新终端失败:{error}"))?; + resume_game_creator_agent_background_tasks_at(root)?; + recovery_scan_required = false; + stable_since = Some(Instant::now()); + continue; + } + let conversation_metrics = read_turn_conversation_metrics( + root, + parent_agent_id, + session_id, + &conversation_baseline, + )?; + let parent_runtime = swarm_parent_runtime(parent_agent_id, session_id, &runtimes); + let completion_blockers = parent_runtime + .map(|parent| swarm_parent_completion_contract_blockers_at(root, parent)) + .unwrap_or_else(|| vec!["parent-runtime-missing".to_string()]); + match classify_swarm_turn_terminal( + parent_runtime, + conversation_metrics, + 0, + 0, + completion_blockers.len(), + ) { + SwarmTurnTerminalClassification::Settled => { + let printed_metrics = print_new_parent_reply( + root, + parent_agent_id, + session_id, + &conversation_baseline, + output, + observer, + )?; + if printed_metrics != conversation_metrics { + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + vec!["conversation-changed-before-settle".to_string()], + ); + } + let report = build_swarm_turn_report( + SwarmTurnReportOutcome::Settled, + parent_agent_id, + session_id, + &runtimes, + conversation_metrics, + 0, + ); + return Ok(SwarmTurnOutcome::Settled(report)); + } + SwarmTurnTerminalClassification::Failed => { + return build_failed_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + vec![format!( + "{}:{}", + parent_agent_id, + parent_runtime + .map(|runtime| runtime.state.phase.as_str()) + .unwrap_or("missing") + )], + ); + } + SwarmTurnTerminalClassification::Incomplete => { + let mut reasons = completion_blockers; + append_swarm_terminal_snapshot_reasons( + &mut reasons, + parent_runtime, + conversation_metrics, + ); + return build_incomplete_turn_outcome( + root, + parent_agent_id, + session_id, + &conversation_baseline, + &runtimes, + reasons, + ); + } + } + } + } + if input_closed { + std::thread::sleep(poll_interval); + continue; + } + match input.recv_timeout(poll_interval) { + Ok(SwarmInputEvent::Line(line)) => { + let Some(command) = parse_swarm_chat_input(&line) else { + continue; + }; + observer.close_response_line(output)?; + match command { + SwarmChatInput::Quit => return Ok(SwarmTurnOutcome::Quit), + SwarmChatInput::Help => print_swarm_chat_help(output)?, + SwarmChatInput::Agents => print_swarm_agents(root, output)?, + SwarmChatInput::Status => print_swarm_status(root, output)?, + SwarmChatInput::History => { + print_conversation_history(root, parent_agent_id, output)? + } + SwarmChatInput::Compact => { + handle_swarm_context_compaction(root, parent_agent_id, output)? + } + SwarmChatInput::Mcp => print_swarm_mcp_status(root, output)?, + SwarmChatInput::Goal(command) => { + let _ = handle_swarm_goal_command(root, parent_agent_id, command, output)?; + stable_since = None; + recovery_scan_required = true; + } + SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?, + SwarmChatInput::Message(message) => { + if let Some(parent) = runtimes.iter().find(|runtime| { + runtime.state.agent_id == parent_agent_id + && matches!(runtime.state.status.as_str(), "pending" | "running") + }) { + let steer_id = format!("swarm-steer-{}", unix_millis()); + let result = steer_game_creator_agent_runtime_task( + root.display().to_string(), + parent_agent_id.to_string(), + parent.state.session_id.clone(), + parent.state.run_id.clone(), + steer_id.clone(), + message, + )?; + writeln!( + output, + "[已追加] run={} steer={} providerInterrupted={}", + parent.state.run_id, steer_id, result.provider_interrupted + ) + .map_err(|error| format!("写入终端失败:{error}"))?; + } else { + writeln!(output, "[暂未发送] 子 Agent 尚未收束,请稍后重发。") + .map_err(|error| format!("写入终端失败:{error}"))?; + } + stable_since = None; + recovery_scan_required = true; + } + } + } + Ok(SwarmInputEvent::Eof) | Err(RecvTimeoutError::Disconnected) => { + mark_swarm_turn_input_closed(&mut input_closed, observer, output)?; + } + Ok(SwarmInputEvent::Error(error)) => { + observer.close_response_line(output)?; + return Err(format!("读取终端输入失败:{error}")); + } + Err(RecvTimeoutError::Timeout) => {} + } + } +} + +pub(super) fn runtimes_are_busy(runtimes: &[AgentRuntimeResult]) -> bool { + runtimes.iter().any(runtime_is_busy) +} + +pub(super) fn runtime_is_busy(runtime: &AgentRuntimeResult) -> bool { + matches!( + runtime.state.status.as_str(), + "pending" + | "running" + | "waiting-for-confirmation" + | "waiting-for-user-input" + | "cancelling" + ) || runtime.state.phase == "needs-reconciliation" + || runtime.task_queue.pending > 0 + || runtime.task_queue.running > 0 + || runtime.task_queue.waiting_for_confirmation > 0 + || runtime.task_queue.waiting_for_user_input > 0 +} + +pub(super) fn mark_swarm_turn_input_closed( + input_closed: &mut bool, + observer: &mut SwarmRuntimeObserver, + output: &mut W, +) -> Result<(), String> { + if *input_closed { + return Ok(()); + } + *input_closed = true; + observer.close_response_line(output)?; + writeln!(output, "[输入已关闭] 当前 turn 继续运行,等待可信终态。") + .map_err(|error| format!("写入终端失败:{error}")) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs index 866c761fa..45093df77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs @@ -1,5614 +1,24 @@ -use std::collections::{BTreeMap, BTreeSet}; +mod content_validation; +mod discovery; +mod identity_order_validation; +mod ledger; +mod model; +mod storage_common; #[cfg(unix)] -use std::fs; +mod storage_unix; +#[cfg(windows)] +mod storage_windows; +#[cfg(test)] +mod tests; +mod thinking; + #[cfg(any(unix, windows))] -use std::fs::File; -#[cfg(windows)] -use std::fs::OpenOptions; -use std::io::{Read, Write}; -use std::path::Path; -#[cfg(any(test, windows))] -use std::path::PathBuf; - -#[cfg(unix)] -use std::ffi::{CStr, CString}; -#[cfg(unix)] -use std::os::fd::{AsRawFd, FromRawFd}; -#[cfg(unix)] -use std::os::unix::ffi::OsStrExt; -#[cfg(unix)] -use std::os::unix::fs::{MetadataExt, PermissionsExt}; - -use platform_llm::{LlmProvider, LlmRunResponse, LlmTokenUsage, LlmToolCall}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -#[cfg(test)] -use crate::agent::agent_runtime_json_sidecar_backup_path; -use crate::agent::redact_secret_tokens; -use crate::provider_retry::{self, validate_identity, AgentRuntimeProviderRetryIdentity}; -use crate::repository_context::redact_absolute_path_tokens; - -pub(crate) const TOOL_PLAN_HANDOFF_SCHEMA_VERSION: &str = "game-creator-tool-plan-handoff.v1"; - -#[cfg(test)] -const TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY: &str = ".agent/runtime/tool-plan-handoffs"; -const TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; -const TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES: usize = 512 * 1024; -const TOOL_PLAN_HANDOFF_MAX_ENTRIES: usize = 128; -const TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS: usize = 32; -const TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES: usize = 256 * 1024; -const TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES: usize = 256 * 1024; -const TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS: usize = 256; -const TOOL_PLAN_HANDOFF_FINISH_REASON_MAX_CHARS: usize = 80; -const TOOL_PLAN_HANDOFF_REQUEST_ID_MAX_CHARS: usize = 256; -const TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS: usize = 512; -const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS: usize = 1024; -const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS: usize = 1024; -const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES: usize = 4096; -#[cfg(test)] -const TOOL_PLAN_HANDOFF_LABEL: &str = "Agent Runtime tool-plan 成功响应交接账本"; -const INVALID_THINKING_OPEN_MARKER: &str = ""; -const INVALID_THINKING_CLOSE_MARKER: &str = ""; -static TOOL_PLAN_HANDOFF_TEMP_NONCE: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(0); - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AgentRuntimeToolPlanHandoffUsage { - prompt_tokens: u64, - completion_tokens: u64, - total_tokens: u64, -} - -impl From<&LlmTokenUsage> for AgentRuntimeToolPlanHandoffUsage { - fn from(value: &LlmTokenUsage) -> Self { - Self { - prompt_tokens: value.prompt_tokens, - completion_tokens: value.completion_tokens, - total_tokens: value.total_tokens, - } - } -} - -impl From<&AgentRuntimeToolPlanHandoffUsage> for LlmTokenUsage { - fn from(value: &AgentRuntimeToolPlanHandoffUsage) -> Self { - Self { - prompt_tokens: value.prompt_tokens, - completion_tokens: value.completion_tokens, - total_tokens: value.total_tokens, - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AgentRuntimeToolPlanHandoffToolCall { - id: String, - name: String, - arguments: String, -} - -impl From<&LlmToolCall> for AgentRuntimeToolPlanHandoffToolCall { - fn from(value: &LlmToolCall) -> Self { - Self { - id: value.id.clone(), - name: value.name.clone(), - arguments: value.arguments.clone(), - } - } -} - -impl From<&AgentRuntimeToolPlanHandoffToolCall> for LlmToolCall { - fn from(value: &AgentRuntimeToolPlanHandoffToolCall) -> Self { - Self { - id: value.id.clone(), - name: value.name.clone(), - arguments: value.arguments.clone(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct AgentRuntimeToolPlanHandoffResponse { - provider: LlmProvider, - model: String, - text: String, - thinking_wrapper_valid: bool, - thinking_wrapper_balanced: bool, - thinking_normalization_count: u32, - thinking_source_text_chars: usize, - thinking_source_text_sha256: Option, - finish_reason: Option, - response_id: Option, - usage: Option, - tool_calls: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AgentRuntimeToolPlanHandoffEntry { - pub(crate) identity: AgentRuntimeProviderRetryIdentity, - pub(crate) provider_request_id: String, - pub(crate) request_slot: String, - pub(crate) attempt: u32, - pub(crate) loop_iteration: u64, - pub(crate) repair_attempt: u32, - response: AgentRuntimeToolPlanHandoffResponse, - pub(crate) response_fingerprint: String, - created_at_ms: u64, -} - -impl AgentRuntimeToolPlanHandoffEntry { - pub(crate) fn to_llm_response(&self) -> LlmRunResponse { - let text = if !self.response.thinking_wrapper_valid { - INVALID_THINKING_CLOSE_MARKER.to_string() - } else if !self.response.thinking_wrapper_balanced { - INVALID_THINKING_OPEN_MARKER.to_string() - } else { - self.response.text.clone() - }; - LlmRunResponse { - provider: self.response.provider, - model: self.response.model.clone(), - text, - finish_reason: self.response.finish_reason.clone(), - response_id: self.response.response_id.clone(), - usage: self.response.usage.as_ref().map(LlmTokenUsage::from), - tool_calls: self - .response - .tool_calls - .iter() - .map(LlmToolCall::from) - .collect(), - } - } - - pub(crate) fn thinking_normalization_metadata(&self) -> Option<(usize, usize, &str)> { - (self.response.thinking_wrapper_valid - && self.response.thinking_wrapper_balanced - && self.response.thinking_normalization_count > 0) - .then(|| { - ( - self.response.thinking_normalization_count as usize, - self.response.thinking_source_text_chars, - self.response - .thinking_source_text_sha256 - .as_deref() - .expect("validated thinking normalization fingerprint"), - ) - }) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct AgentRuntimeToolPlanHandoffLedger { - schema_version: String, - agent_id: String, - run_id: String, - pub(crate) entries: Vec, -} - -impl AgentRuntimeToolPlanHandoffLedger { - pub(crate) fn agent_id(&self) -> &str { - &self.agent_id - } - - pub(crate) fn run_id(&self) -> &str { - &self.run_id - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum AgentRuntimeToolPlanHandoffLookup { - Missing, - Exact(AgentRuntimeToolPlanHandoffEntry), - IdentityConflict(AgentRuntimeToolPlanHandoffEntry), -} - -pub(crate) fn read_for_run_at( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result, String> { - validate_path_identity(agent_id, run_id)?; - #[cfg(unix)] - { - return read_for_run_at_unix(root, agent_id, run_id); - } - #[cfg(windows)] - { - return read_for_run_at_windows(root, agent_id, run_id); - } - #[cfg(not(any(unix, windows)))] - { - let _ = root; - Err("当前平台不支持安全 tool-plan 成功响应交接存储".to_string()) - } -} - -fn serialize_ledger_for_storage( - ledger: &AgentRuntimeToolPlanHandoffLedger, -) -> Result, String> { - let mut bytes = serde_json::to_vec_pretty(ledger) - .map_err(|error| format!("序列化 tool-plan 成功响应交接账本失败:{error}"))?; - bytes.push(b'\n'); - if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { - return Err(format!( - "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" - )); - } - Ok(bytes) -} - -fn write_ledger_at(root: &Path, ledger: &AgentRuntimeToolPlanHandoffLedger) -> Result<(), String> { - let bytes = serialize_ledger_for_storage(ledger)?; - #[cfg(unix)] - { - return write_ledger_at_unix(root, ledger, &bytes); - } - #[cfg(windows)] - { - return write_ledger_at_windows(root, ledger, &bytes); - } - #[cfg(not(any(unix, windows)))] - { - let _ = (root, bytes); - Err("当前平台不支持安全 tool-plan 成功响应交接写入".to_string()) - } -} - -#[cfg(unix)] -pub(crate) fn list_at(root: &Path) -> Result, String> { - list_at_unix_with_agent_open_hook(root, |_| {}) -} - -#[cfg(unix)] -fn list_at_unix_with_agent_open_hook( - root: &Path, - mut after_agent_open: F, -) -> Result, String> -where - F: FnMut(&str), -{ - let project_directory = open_unix_tool_plan_root(root)?; - let Some(agent_runtime_directory) = - open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? - else { - return Ok(Vec::new()); - }; - let Some(runtime_directory) = open_unix_tool_plan_directory_at( - &agent_runtime_directory, - "runtime", - "Agent Runtime 目录", - )? - else { - return Ok(Vec::new()); - }; - let Some(handoff_directory) = open_unix_tool_plan_directory_at( - &runtime_directory, - "tool-plan-handoffs", - "tool-plan 成功响应交接根目录", - )? - else { - return Ok(Vec::new()); - }; - lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; - - let agent_names = - read_unix_tool_plan_directory_names(&handoff_directory, "tool-plan 成功响应交接根目录")?; - if agent_names.len() > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS { - return Err(format!( - "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS} 个 Agent 上限" - )); - } - let mut discovered = BTreeMap::<(String, String), DiscoveredToolPlanHandoffLedgers>::new(); - let mut discovered_file_count = 0usize; - for agent_key in agent_names { - if !is_handoff_path_key(&agent_key) { - return Err(format!( - "tool-plan 成功响应交接 Agent 目录名不是规范 hash:{agent_key}" - )); - } - let agent_directory = open_unix_tool_plan_directory_at( - &handoff_directory, - &agent_key, - "tool-plan 成功响应交接 Agent 目录", - )? - .ok_or_else(|| "tool-plan 成功响应交接 Agent 目录在扫描期间消失".to_string())?; - lock_unix_tool_plan_directory(&agent_directory, "tool-plan 成功响应交接 Agent 目录")?; - after_agent_open(&agent_key); - let run_names = read_unix_tool_plan_directory_names( - &agent_directory, - "tool-plan 成功响应交接 Agent 目录", - )?; - if run_names.len() > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES - || discovered_file_count.saturating_add(run_names.len()) - > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES - { - return Err(format!( - "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES} 个文件上限" - )); - } - discovered_file_count = discovered_file_count.saturating_add(run_names.len()); - for file_name in run_names { - match classify_handoff_file_name(&file_name) { - Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) => { - let ledger = read_unix_discovered_ledger_file( - root, - &agent_directory, - &file_name, - &agent_key, - run_key, - )?; - let files = - discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; - if files.primary.replace(ledger).is_some() { - return Err("tool-plan 成功响应交接 primary 项冲突".to_string()); - } - } - Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) => { - let ledger = read_unix_discovered_ledger_file( - root, - &agent_directory, - &file_name, - &agent_key, - run_key, - )?; - let files = - discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; - if files.previous.replace(ledger).is_some() { - return Err("tool-plan 成功响应交接 .previous 项冲突".to_string()); - } - } - Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => { - if !is_handoff_path_key(run_key) { - return Err(format!( - "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" - )); - } - remove_stale_unix_handoff_temp_file_at(&agent_directory, &file_name)?; - } - None => { - return Err(format!( - "tool-plan 成功响应交接目录包含未知文件:{file_name}" - )); - } - } - } - verify_unix_tool_plan_entry( - &handoff_directory, - &agent_key, - &agent_directory, - true, - "tool-plan 成功响应交接 Agent 目录", - )?; - } - verify_unix_tool_plan_entry( - &runtime_directory, - "tool-plan-handoffs", - &handoff_directory, - true, - "tool-plan 成功响应交接根目录", - )?; - verify_unix_tool_plan_entry( - &agent_runtime_directory, - "runtime", - &runtime_directory, - true, - "Agent Runtime 目录", - )?; - verify_unix_tool_plan_entry( - &project_directory, - ".agent", - &agent_runtime_directory, - true, - "项目 .agent 目录", - )?; - verify_unix_tool_plan_root(root, &project_directory)?; - - let mut ledgers = Vec::with_capacity(discovered.len()); - for ((agent_key, run_key), files) in discovered { - let selected = match (files.primary, files.previous) { - (Some(primary), Some(previous)) => { - if !ledger_is_prefix(&previous, &primary) { - return Err(format!( - "tool-plan 成功响应交接 primary/.previous 内容冲突:{agent_key}/{run_key}" - )); - } - primary - } - (Some(primary), None) => primary, - (None, Some(previous)) => previous, - (None, None) => continue, - }; - ledgers.push(selected); - } - ledgers.sort_by(|left, right| { - left.agent_id - .cmp(&right.agent_id) - .then_with(|| left.run_id.cmp(&right.run_id)) - }); - Ok(ledgers) -} - -#[cfg(windows)] -pub(crate) fn list_at(root: &Path) -> Result, String> { - list_at_windows(root) -} - -pub(crate) fn lookup_at( - root: &Path, - agent_id: &str, - run_id: &str, - identity: &AgentRuntimeProviderRetryIdentity, -) -> Result { - validate_tool_plan_identity(identity)?; - validate_path_identity(agent_id, run_id)?; - if identity.agent_id != agent_id || identity.run_id != run_id { - return Err("tool-plan 成功响应交接查询身份与路径 Agent/run 冲突".to_string()); - } - let (loop_iteration, repair_attempt) = - parse_tool_plan_base_request_slot(&identity.base_request_slot)?; - let Some(ledger) = read_for_run_at(root, agent_id, run_id)? else { - return Ok(AgentRuntimeToolPlanHandoffLookup::Missing); - }; - let target = (loop_iteration, repair_attempt); - let Some(entry) = ledger.entries.iter().find(|entry| { - entry.loop_iteration == loop_iteration && entry.repair_attempt == repair_attempt - }) else { - if ledger - .entries - .iter() - .any(|entry| (entry.loop_iteration, entry.repair_attempt) > target) - { - return Err("tool-plan 成功响应交接账本包含当前请求之后的未来 entry".to_string()); - } - return Ok(AgentRuntimeToolPlanHandoffLookup::Missing); - }; - if entry.identity == *identity { - Ok(AgentRuntimeToolPlanHandoffLookup::Exact(entry.clone())) - } else { - Ok(AgentRuntimeToolPlanHandoffLookup::IdentityConflict( - entry.clone(), - )) - } -} - -pub(crate) fn ensure_capacity_for_request_at( - root: &Path, - agent_id: &str, - run_id: &str, - identity: &AgentRuntimeProviderRetryIdentity, -) -> Result<(), String> { - validate_tool_plan_identity(identity)?; - validate_path_identity(agent_id, run_id)?; - if identity.agent_id != agent_id || identity.run_id != run_id { - return Err("tool-plan 请求前容量门禁与路径 Agent/run 身份冲突".to_string()); - } - let target = parse_tool_plan_base_request_slot(&identity.base_request_slot)?; - let Some(ledger) = read_for_run_at(root, agent_id, run_id)? else { - return Ok(()); - }; - if ledger.entries.iter().any(|entry| { - (entry.loop_iteration, entry.repair_attempt) == target && entry.identity == *identity - }) { - return Ok(()); - } - if ledger.entries.len() >= TOOL_PLAN_HANDOFF_MAX_ENTRIES { - return Err(format!( - "tool-plan 请求前账本容量已耗尽:同一 run 最多 {TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条成功响应" - )); - } - let current_bytes = serde_json::to_vec_pretty(&ledger) - .map_err(|error| format!("序列化 tool-plan 请求前容量快照失败:{error}"))? - .len() - .saturating_add(1); - if current_bytes.saturating_add(TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES) - > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES - { - return Err(format!( - "tool-plan 请求前账本剩余空间不足:至少需要预留 {TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES} 字节" - )); - } - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn write_at( - root: &Path, - identity: &AgentRuntimeProviderRetryIdentity, - request_slot: &str, - attempt: u32, - provider_request_id: &str, - response: &LlmRunResponse, -) -> Result { - validate_tool_plan_identity(identity)?; - validate_provider_request_id(provider_request_id)?; - let (loop_iteration, repair_attempt) = - parse_tool_plan_base_request_slot(&identity.base_request_slot)?; - let response = response_for_persistence(root, response)?; - let response_fingerprint = response_fingerprint(&response)?; - let entry = AgentRuntimeToolPlanHandoffEntry { - identity: identity.clone(), - provider_request_id: provider_request_id.to_string(), - request_slot: request_slot.to_string(), - attempt, - loop_iteration, - repair_attempt, - response, - response_fingerprint, - created_at_ms: provider_retry::now_ms(), - }; - validate_entry(root, &entry)?; - - let mut ledger = match read_for_run_at(root, &identity.agent_id, &identity.run_id)? { - Some(mut ledger) => { - if let Some(existing) = ledger.entries.iter().find(|existing| { - existing.loop_iteration == loop_iteration - && existing.repair_attempt == repair_attempt - }) { - if entry_payload_matches(existing, &entry) { - return Ok(existing.clone()); - } - return Err("tool-plan 成功响应交接同一 slot 内容冲突".to_string()); - } - if ledger.entries.len() >= TOOL_PLAN_HANDOFF_MAX_ENTRIES { - return Err(format!( - "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条上限" - )); - } - let previous = ledger - .entries - .last() - .ok_or_else(|| "tool-plan 成功响应交接账本不能为空".to_string())?; - validate_next_entry(previous, &entry)?; - ledger.entries.push(entry.clone()); - ledger - } - None => { - if repair_attempt != 0 { - return Err("tool-plan 成功响应交接新 loop 首条必须为 repair-0".to_string()); - } - AgentRuntimeToolPlanHandoffLedger { - schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), - agent_id: identity.agent_id.clone(), - run_id: identity.run_id.clone(), - entries: vec![entry.clone()], - } - } - }; - validate_ledger(root, &ledger)?; - - write_ledger_at(root, &ledger)?; - let persisted = read_for_run_at(root, &identity.agent_id, &identity.run_id)? - .ok_or_else(|| "tool-plan 成功响应交接账本写入后不存在".to_string())?; - if persisted != ledger { - return Err("tool-plan 成功响应交接账本写入后内容冲突".to_string()); - } - ledger = persisted; - ledger - .entries - .into_iter() - .find(|persisted_entry| { - persisted_entry.loop_iteration == loop_iteration - && persisted_entry.repair_attempt == repair_attempt - }) - .ok_or_else(|| "tool-plan 成功响应交接 entry 写入后不存在".to_string()) -} - -pub(crate) fn remove_at(root: &Path, agent_id: &str, run_id: &str) -> Result<(), String> { - validate_path_identity(agent_id, run_id)?; - #[cfg(unix)] - { - return remove_at_unix_with_agent_open_hook(root, agent_id, run_id, |_| {}); - } - #[cfg(windows)] - { - return remove_at_windows(root, agent_id, run_id); - } - #[cfg(not(any(unix, windows)))] - { - let _ = root; - Err("当前平台不支持安全 tool-plan 成功响应交接删除".to_string()) - } -} - -pub(crate) fn is_later_repair_identity( - current: &AgentRuntimeProviderRetryIdentity, - candidate: &AgentRuntimeProviderRetryIdentity, -) -> bool { - let Ok((current_loop, current_repair)) = validate_tool_plan_identity(current) else { - return false; - }; - let Ok((candidate_loop, candidate_repair)) = validate_tool_plan_identity(candidate) else { - return false; - }; - current_loop == candidate_loop - && candidate_repair > current_repair - && same_tool_plan_repair_chain(current, candidate) -} - -fn response_for_persistence( - root: &Path, - response: &LlmRunResponse, -) -> Result { - let thinking = normalize_thinking_for_persistence(&response.text); - let persisted = AgentRuntimeToolPlanHandoffResponse { - provider: response.provider, - model: response.model.clone(), - text: thinking.persisted_text, - thinking_wrapper_valid: thinking.wrapper_valid, - thinking_wrapper_balanced: thinking.wrapper_balanced, - thinking_normalization_count: thinking.complete_block_count, - thinking_source_text_chars: thinking.source_text_chars, - thinking_source_text_sha256: thinking.source_text_sha256, - finish_reason: response.finish_reason.clone(), - response_id: response.response_id.clone(), - usage: response - .usage - .as_ref() - .map(AgentRuntimeToolPlanHandoffUsage::from), - tool_calls: response - .tool_calls - .iter() - .map(AgentRuntimeToolPlanHandoffToolCall::from) - .collect(), - }; - validate_response(root, &persisted)?; - Ok(persisted) -} - -fn validate_ledger(root: &Path, ledger: &AgentRuntimeToolPlanHandoffLedger) -> Result<(), String> { - if ledger.schema_version != TOOL_PLAN_HANDOFF_SCHEMA_VERSION { - return Err(format!( - "不支持的 tool-plan 成功响应交接版本:{}", - ledger.schema_version - )); - } - validate_path_identity(&ledger.agent_id, &ledger.run_id)?; - if ledger.entries.is_empty() || ledger.entries.len() > TOOL_PLAN_HANDOFF_MAX_ENTRIES { - return Err(format!( - "tool-plan 成功响应交接账本 entries 必须为 1..={TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条" - )); - } - - let mut provider_request_ids = BTreeSet::new(); - let mut previous = None; - for entry in &ledger.entries { - validate_entry(root, entry)?; - if entry.identity.agent_id != ledger.agent_id || entry.identity.run_id != ledger.run_id { - return Err("tool-plan 成功响应交接 entry 与账本 Agent/run 身份冲突".to_string()); - } - if !provider_request_ids.insert(entry.provider_request_id.as_str()) { - return Err("tool-plan 成功响应交接 providerRequestId 重复".to_string()); - } - match previous { - Some(previous) => validate_next_entry(previous, entry)?, - None if entry.repair_attempt != 0 => { - return Err("tool-plan 成功响应交接新 loop 首条必须为 repair-0".to_string()); - } - None => {} - } - previous = Some(entry); - } - Ok(()) -} - -fn validate_entry(root: &Path, entry: &AgentRuntimeToolPlanHandoffEntry) -> Result<(), String> { - let (loop_iteration, repair_attempt) = validate_tool_plan_identity(&entry.identity)?; - if entry.loop_iteration != loop_iteration || entry.repair_attempt != repair_attempt { - return Err("tool-plan 成功响应交接 loop/repair 与 identity 不匹配".to_string()); - } - validate_provider_request_id(&entry.provider_request_id)?; - if entry.request_slot != request_slot_for_attempt(&entry.identity, entry.attempt) { - return Err("tool-plan 成功响应交接 requestSlot/attempt 无效".to_string()); - } - validate_response(root, &entry.response)?; - if entry.response_fingerprint != response_fingerprint(&entry.response)? { - return Err("tool-plan 成功响应交接 responseFingerprint 不匹配".to_string()); - } - if entry.created_at_ms == 0 { - return Err("tool-plan 成功响应交接 createdAtMs 无效".to_string()); - } - Ok(()) -} - -fn validate_response( - root: &Path, - response: &AgentRuntimeToolPlanHandoffResponse, -) -> Result<(), String> { - validate_short_metadata( - root, - "model", - &response.model, - TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, - false, - )?; - if response.text.len() > TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES { - return Err(format!( - "tool-plan 成功响应交接 text 超过 {TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES} 字节上限" - )); - } - let persisted_thinking = normalize_thinking_for_persistence(&response.text); - if persisted_thinking.saw_wrapper - || !persisted_thinking.wrapper_valid - || !persisted_thinking.wrapper_balanced - || persisted_thinking.persisted_text != response.text - { - return Err("tool-plan 成功响应交接 text 仍包含 thinking block".to_string()); - } - match ( - response.thinking_wrapper_valid, - response.thinking_wrapper_balanced, - response.thinking_normalization_count, - response.thinking_source_text_chars, - response.thinking_source_text_sha256.as_deref(), - ) { - (true, true, 0, 0, None) => {} - (true, true, count, chars, Some(fingerprint)) - if count > 0 - && chars > 0 - && fingerprint.len() == 64 - && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} - (wrapper_valid, wrapper_balanced, _, chars, Some(fingerprint)) - if (!wrapper_valid || !wrapper_balanced) - && response.text.is_empty() - && chars > 0 - && fingerprint.len() == 64 - && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} - _ => return Err("tool-plan 成功响应交接 thinking normalization 元数据无效".to_string()), - } - validate_private_content(root, "text", &response.text, true)?; - if let Some(finish_reason) = response.finish_reason.as_deref() { - validate_short_metadata( - root, - "finishReason", - finish_reason, - TOOL_PLAN_HANDOFF_FINISH_REASON_MAX_CHARS, - true, - )?; - } - if let Some(response_id) = response.response_id.as_deref() { - validate_short_metadata( - root, - "responseId", - response_id, - TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, - true, - )?; - } - if response.tool_calls.len() > TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS { - return Err(format!( - "tool-plan 成功响应交接 tool calls 超过 {TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS} 条上限" - )); - } - for call in &response.tool_calls { - validate_short_metadata( - root, - "tool call id", - &call.id, - TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, - false, - )?; - validate_short_metadata( - root, - "tool call name", - &call.name, - TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, - false, - )?; - if call.arguments.len() > TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES { - return Err(format!( - "tool-plan 成功响应交接 arguments 超过 {TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES} 字节上限" - )); - } - validate_private_content(root, "arguments", &call.arguments, true)?; - } - if response.thinking_wrapper_valid - && response.thinking_wrapper_balanced - && response.text.trim().is_empty() - && response.tool_calls.is_empty() - { - return Err("tool-plan 成功响应交接响应不能为空".to_string()); - } - Ok(()) -} - -fn validate_private_content( - root: &Path, - label: &str, - value: &str, - scan_json_escapes: bool, -) -> Result<(), String> { - validate_private_content_view(root, label, value)?; - validate_json_like_sensitive_keys(label, value)?; - let parsed = serde_json::from_str::(value); - if let Ok(json) = &parsed { - validate_json_sensitive_keys(label, json)?; - validate_json_absolute_path_inputs(label, json, None)?; - if let serde_json::Value::String(inner) = json { - validate_private_content_view(root, label, inner)?; - validate_json_like_sensitive_keys(label, inner)?; - if let Ok(inner_json) = serde_json::from_str::(inner) { - validate_json_sensitive_keys(label, &inner_json)?; - validate_json_absolute_path_inputs(label, &inner_json, None)?; - } - } - } else if scan_json_escapes { - let scan_view = decode_json_escaped_scan_view(value); - if scan_view != value { - validate_private_content_view(root, label, &scan_view)?; - validate_json_like_sensitive_keys(label, &scan_view)?; - if let Ok(json) = serde_json::from_str::(&scan_view) { - validate_json_sensitive_keys(label, &json)?; - validate_json_absolute_path_inputs(label, &json, None)?; - } - } - let json_like_payload = label == "arguments" - || value - .trim_start() - .as_bytes() - .first() - .is_some_and(|byte| matches!(byte, b'{' | b'[')); - if json_like_payload { - validate_json_like_absolute_path_inputs(label, value)?; - if scan_view != value { - validate_json_like_absolute_path_inputs(label, &scan_view)?; - } - } - } - Ok(()) -} - -fn validate_private_content_view(_root: &Path, label: &str, value: &str) -> Result<(), String> { - let lower = value.to_ascii_lowercase(); - let sensitive_rule = [ - ".env", - "game-creator.config", - "authorization:", - "cookie:", - "bearer ", - ] - .into_iter() - .position(|marker| lower.contains(marker)) - .or_else(|| (redact_secret_tokens(value) != value).then_some(5)); - if let Some(rule) = sensitive_rule { - return Err(format!( - "tool-plan 成功响应交接 {label} 命中敏感规则 #{rule}" - )); - } - if value - .chars() - .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) - { - return Err(format!("tool-plan 成功响应交接 {label} 包含不安全控制字符")); - } - Ok(()) -} - -fn validate_json_sensitive_keys(label: &str, value: &serde_json::Value) -> Result<(), String> { - match value { - serde_json::Value::Array(values) => { - for value in values { - validate_json_sensitive_keys(label, value)?; - } - } - serde_json::Value::Object(values) => { - for (key, value) in values { - if is_sensitive_json_key(key) { - return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); - } - validate_json_sensitive_keys(label, value)?; - } - } - _ => {} - } - Ok(()) -} - -fn validate_json_like_sensitive_keys(label: &str, value: &str) -> Result<(), String> { - let bytes = value.as_bytes(); - let mut index = 0usize; - while index < bytes.len() { - match bytes[index] { - b'"' | b'\'' => { - let quote = bytes[index]; - let Some((key, next_index)) = decode_json_like_quoted_token(value, index, quote) - else { - index += 1; - continue; - }; - index = next_index; - let separator = skip_json_like_trivia(value, index); - if bytes.get(separator) == Some(&b':') && is_sensitive_json_key(&key) { - return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); - } - } - byte if is_json_like_key_byte(byte) => { - let start = index; - while bytes.get(index).copied().is_some_and(is_json_like_key_byte) { - index += 1; - } - let separator = skip_json_like_trivia(value, index); - if bytes.get(separator) == Some(&b':') - && is_sensitive_json_key(&value[start..index]) - { - return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); - } - } - _ => index += 1, - } - } - Ok(()) -} - -fn decode_json_like_quoted_token(value: &str, start: usize, quote: u8) -> Option<(String, usize)> { - let bytes = value.as_bytes(); - let mut output = String::new(); - let mut index = start.checked_add(1)?; - while index < bytes.len() { - match bytes[index] { - byte if byte == quote => return Some((output, index + 1)), - b'\\' => { - let escaped = *bytes.get(index + 1)?; - if escaped == b'u' { - let decoded = index - .checked_add(6) - .and_then(|end| value.get(index + 2..end)) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32); - if let Some(character) = decoded { - output.push(character); - index += 6; - } else { - output.push('\\'); - output.push('u'); - index += 2; - } - } else { - if let Some(character) = match escaped { - b'"' => Some('"'), - b'\'' => Some('\''), - b'\\' => Some('\\'), - b'/' => Some('/'), - b'b' => Some('\u{0008}'), - b'f' => Some('\u{000c}'), - b'n' => Some('\n'), - b'r' => Some('\r'), - b't' => Some('\t'), - _ => None, - } { - output.push(character); - } else { - output.push('\\'); - output.push(escaped as char); - } - index += 2; - } - } - _ => { - let character = value[index..].chars().next()?; - output.push(character); - index += character.len_utf8(); - } - } - } - None -} - -fn is_json_like_key_byte(byte: u8) -> bool { - byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') -} - -fn skip_json_like_trivia(value: &str, mut index: usize) -> usize { - let bytes = value.as_bytes(); - loop { - while bytes - .get(index) - .is_some_and(|byte| byte.is_ascii_whitespace()) - { - index += 1; - } - if bytes.get(index..index.saturating_add(2)) == Some(b"//") { - index += 2; - while bytes - .get(index) - .is_some_and(|byte| !matches!(byte, b'\n' | b'\r')) - { - index += 1; - } - continue; - } - if bytes.get(index..index.saturating_add(2)) == Some(b"/*") { - index += 2; - while index < bytes.len() && bytes.get(index..index.saturating_add(2)) != Some(b"*/") { - index += 1; - } - if index >= bytes.len() { - return bytes.len(); - } - index += 2; - continue; - } - return index; - } -} - -fn is_sensitive_json_key(key: &str) -> bool { - let normalized = key - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect::(); - [ - "xapikey", - "apikey", - "accesstoken", - "refreshtoken", - "password", - "clientsecret", - "privatekey", - "credential", - "authorization", - "cookie", - "secret", - ] - .into_iter() - .any(|sensitive| normalized.contains(sensitive)) - || ["token", "tokens"] - .into_iter() - .any(|sensitive| normalized == sensitive || normalized.ends_with(sensitive)) -} - -fn validate_json_absolute_path_inputs( - label: &str, - value: &serde_json::Value, - parent_key: Option<&str>, -) -> Result<(), String> { - match value { - serde_json::Value::String(value) => { - if !parent_key.is_some_and(is_tool_plan_content_field) - && redact_absolute_path_tokens(value) != *value - { - return Err(format!( - "tool-plan 成功响应交接 {label} 的结构化输入包含绝对路径" - )); - } - } - serde_json::Value::Array(values) => { - for value in values { - validate_json_absolute_path_inputs(label, value, parent_key)?; - } - } - serde_json::Value::Object(values) => { - for (key, value) in values { - validate_json_absolute_path_inputs(label, value, Some(key))?; - } - } - _ => {} - } - Ok(()) -} - -fn validate_json_like_absolute_path_inputs(label: &str, value: &str) -> Result<(), String> { - let bytes = value.as_bytes(); - let mut index = 0usize; - while index < bytes.len() { - let (key, next_index) = match bytes[index] { - b'"' | b'\'' => { - let quote = bytes[index]; - let Some(decoded) = decode_json_like_quoted_token(value, index, quote) else { - index += 1; - continue; - }; - decoded - } - byte if is_json_like_key_byte(byte) => { - let start = index; - while bytes.get(index).copied().is_some_and(is_json_like_key_byte) { - index += 1; - } - (value[start..index].to_string(), index) - } - _ => { - index += 1; - continue; - } - }; - index = next_index; - let separator = skip_json_like_trivia(value, index); - if bytes.get(separator) != Some(&b':') { - continue; - } - let value_start = skip_json_like_trivia(value, separator + 1); - let content_field = is_tool_plan_content_field(&key); - match bytes.get(value_start).copied() { - Some(quote @ (b'"' | b'\'')) => { - if let Some((candidate, next_value)) = - decode_json_like_quoted_token(value, value_start, quote) - { - if !content_field && redact_absolute_path_tokens(&candidate) != candidate { - return Err(format!( - "tool-plan 成功响应交接 {label} 的 JSON-like 输入包含绝对路径" - )); - } - index = next_value; - } else if !content_field { - let candidate = decode_json_escaped_scan_view( - value.get(value_start + 1..).unwrap_or_default(), - ); - if redact_absolute_path_tokens(&candidate) != candidate { - return Err(format!( - "tool-plan 成功响应交接 {label} 的未闭合 JSON-like 字符串包含绝对路径" - )); - } - } - } - Some(b'[') if !content_field => { - let mut array_index = value_start + 1; - while let Some(byte) = bytes.get(array_index).copied() { - if byte == b']' { - break; - } - if matches!(byte, b'"' | b'\'') { - if let Some((candidate, next_value)) = - decode_json_like_quoted_token(value, array_index, byte) - { - if redact_absolute_path_tokens(&candidate) != candidate { - return Err(format!( - "tool-plan 成功响应交接 {label} 的 JSON-like 数组包含绝对路径" - )); - } - array_index = next_value; - continue; - } else { - let candidate = decode_json_escaped_scan_view( - value.get(array_index + 1..).unwrap_or_default(), - ); - if redact_absolute_path_tokens(&candidate) != candidate { - return Err(format!( - "tool-plan 成功响应交接 {label} 的未闭合 JSON-like 数组包含绝对路径" - )); - } - break; - } - } - array_index += 1; - } - } - Some(_) if !content_field => { - let end = bytes[value_start..] - .iter() - .position(|byte| { - byte.is_ascii_whitespace() || matches!(byte, b',' | b'}' | b']') - }) - .map(|offset| value_start + offset) - .unwrap_or(bytes.len()); - let candidate = &value[value_start..end]; - if redact_absolute_path_tokens(candidate) != candidate { - return Err(format!( - "tool-plan 成功响应交接 {label} 的 JSON-like 输入包含绝对路径" - )); - } - } - _ => {} - } - } - Ok(()) -} - -fn is_tool_plan_content_field(key: &str) -> bool { - let key = key.to_ascii_lowercase(); - [ - "body", - "code", - "content", - "css", - "detail", - "explanation", - "html", - "instruction", - "message", - "newtext", - "oldtext", - "patch", - "plan", - "prompt", - "query", - "reason", - "response", - "script", - "summary", - "task", - "text", - "thinkingsummary", - "step", - "title", - ] - .contains(&key.as_str()) -} - -struct PersistedThinkingNormalization { - persisted_text: String, - wrapper_valid: bool, - wrapper_balanced: bool, - saw_wrapper: bool, - complete_block_count: u32, - source_text_chars: usize, - source_text_sha256: Option, -} - -fn normalize_thinking_for_persistence(value: &str) -> PersistedThinkingNormalization { - const THINK_START: &str = ""; - const THINK_END: &str = ""; - let lower = value.to_ascii_lowercase(); - let mut output = String::new(); - let mut cursor = 0usize; - let mut scan = 0usize; - let mut depth = 0u32; - let mut count = 0u32; - let mut saw_wrapper = false; - let mut wrapper_valid = true; - loop { - let next_start = lower[scan..].find(THINK_START).map(|index| scan + index); - let next_end = lower[scan..].find(THINK_END).map(|index| scan + index); - match (next_start, next_end) { - (Some(start), Some(end)) if start < end => { - saw_wrapper = true; - if depth == 0 { - output.push_str(&value[cursor..start]); - } - depth = depth.saturating_add(1); - scan = start + THINK_START.len(); - } - (Some(start), None) => { - saw_wrapper = true; - if depth == 0 { - output.push_str(&value[cursor..start]); - } - depth = depth.saturating_add(1); - scan = start + THINK_START.len(); - } - (_, Some(end)) => { - saw_wrapper = true; - scan = end + THINK_END.len(); - if depth == 0 { - wrapper_valid = false; - continue; - } - depth -= 1; - if depth == 0 { - cursor = scan; - count = count.saturating_add(1); - } - } - (None, None) => break, - } - } - let wrapper_balanced = depth == 0; - let persisted_text = if wrapper_valid && wrapper_balanced { - output.push_str(&value[cursor..]); - output.trim().to_string() - } else { - String::new() - }; - let (source_text_chars, source_text_sha256) = if saw_wrapper { - ( - value.chars().count(), - Some(format!("{:x}", Sha256::digest(value.as_bytes()))), - ) - } else { - (0, None) - }; - PersistedThinkingNormalization { - persisted_text, - wrapper_valid, - wrapper_balanced, - saw_wrapper, - complete_block_count: count, - source_text_chars, - source_text_sha256, - } -} - -fn decode_json_escaped_scan_view(value: &str) -> String { - let bytes = value.as_bytes(); - let mut output = String::with_capacity(value.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] != b'\\' { - let character = value[index..].chars().next().unwrap_or_default(); - output.push(character); - index += character.len_utf8(); - continue; - } - let Some(escaped) = bytes.get(index + 1).copied() else { - output.push('\\'); - break; - }; - match escaped { - b'"' => output.push('"'), - b'\\' => output.push('\\'), - b'/' => output.push('/'), - b'b' => output.push('\u{0008}'), - b'f' => output.push('\u{000c}'), - b'n' => output.push('\n'), - b'r' => output.push('\r'), - b't' => output.push('\t'), - b'u' => { - let Some(hex) = value.get(index + 2..index.saturating_add(6)) else { - output.push('\\'); - index += 1; - continue; - }; - let Ok(codepoint) = u32::from_str_radix(hex, 16) else { - output.push('\\'); - index += 1; - continue; - }; - let Some(character) = char::from_u32(codepoint) else { - output.push('\\'); - index += 1; - continue; - }; - output.push(character); - index += 6; - continue; - } - _ => { - output.push('\\'); - index += 1; - continue; - } - } - index += 2; - } - output -} - -fn validate_tool_plan_identity( - identity: &AgentRuntimeProviderRetryIdentity, -) -> Result<(u64, u32), String> { - validate_identity(identity)?; - if identity.request_kind != "tool-plan" || identity.allow_idle_context_compaction { - return Err("tool-plan 成功响应交接 identity requestKind/compaction 无效".to_string()); - } - for (label, value) in [ - ("projectId", identity.project_id.as_str()), - ("agentId", identity.agent_id.as_str()), - ("taskId", identity.task_id.as_str()), - ("sessionId", identity.session_id.as_str()), - ("runId", identity.run_id.as_str()), - ("source", identity.source.as_str()), - ] { - validate_short_text( - label, - value, - TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS, - false, - )?; - } - if let Some(goal_id) = identity.goal_id.as_deref() { - validate_short_text( - "goalId", - goal_id, - TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS, - false, - )?; - } - parse_tool_plan_base_request_slot(&identity.base_request_slot) -} - -fn parse_tool_plan_base_request_slot(value: &str) -> Result<(u64, u32), String> { - let Some(rest) = value.strip_prefix("loop-") else { - return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); - }; - let Some((loop_text, repair_text)) = rest.split_once("-repair-") else { - return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); - }; - if loop_text.is_empty() - || repair_text.is_empty() - || !loop_text.bytes().all(|byte| byte.is_ascii_digit()) - || !repair_text.bytes().all(|byte| byte.is_ascii_digit()) - { - return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); - } - let loop_iteration = loop_text - .parse::() - .map_err(|_| "tool-plan 成功响应交接 loopIteration 溢出".to_string())?; - let repair_attempt = repair_text - .parse::() - .map_err(|_| "tool-plan 成功响应交接 repairAttempt 溢出".to_string())?; - if value != format!("loop-{loop_iteration}-repair-{repair_attempt}") { - return Err("tool-plan 成功响应交接 baseRequestSlot 非规范格式".to_string()); - } - Ok((loop_iteration, repair_attempt)) -} - -fn validate_next_entry( - previous: &AgentRuntimeToolPlanHandoffEntry, - candidate: &AgentRuntimeToolPlanHandoffEntry, -) -> Result<(), String> { - if !same_durable_tool_plan_run(&previous.identity, &candidate.identity) { - return Err("tool-plan 成功响应交接 entry 的 durable run 身份冲突".to_string()); - } - if candidate.loop_iteration == previous.loop_iteration { - if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) { - return Err("tool-plan 成功响应交接同 loop repair 链身份冲突".to_string()); - } - let expected_repair = previous - .repair_attempt - .checked_add(1) - .ok_or_else(|| "tool-plan 成功响应交接 repairAttempt 溢出".to_string())?; - if candidate.repair_attempt != expected_repair { - return Err("tool-plan 成功响应交接同 loop 的 repair 必须连续追加".to_string()); - } - return Ok(()); - } - if candidate.loop_iteration > previous.loop_iteration && candidate.repair_attempt == 0 { - return Ok(()); - } - Err("tool-plan 成功响应交接 entry 顺序无效,新 loop 必须从 repair-0 开始".to_string()) -} - -fn same_durable_tool_plan_run( - current: &AgentRuntimeProviderRetryIdentity, - candidate: &AgentRuntimeProviderRetryIdentity, -) -> bool { - current.project_id == candidate.project_id - && current.agent_id == candidate.agent_id - && current.task_id == candidate.task_id - && current.session_id == candidate.session_id - && current.run_id == candidate.run_id - && current.source == candidate.source - && current.request_kind == candidate.request_kind -} - -fn entry_payload_matches( - left: &AgentRuntimeToolPlanHandoffEntry, - right: &AgentRuntimeToolPlanHandoffEntry, -) -> bool { - left.identity == right.identity - && left.provider_request_id == right.provider_request_id - && left.request_slot == right.request_slot - && left.attempt == right.attempt - && left.loop_iteration == right.loop_iteration - && left.repair_attempt == right.repair_attempt - && left.response == right.response - && left.response_fingerprint == right.response_fingerprint -} - -fn same_tool_plan_repair_chain( - current: &AgentRuntimeProviderRetryIdentity, - candidate: &AgentRuntimeProviderRetryIdentity, -) -> bool { - current.project_id == candidate.project_id - && current.agent_id == candidate.agent_id - && current.task_id == candidate.task_id - && current.session_id == candidate.session_id - && current.run_id == candidate.run_id - && current.source == candidate.source - && current.goal_id == candidate.goal_id - && current.goal_revision == candidate.goal_revision - && current.goal_snapshot_fingerprint == candidate.goal_snapshot_fingerprint - && current.applied_steer_cursor == candidate.applied_steer_cursor - && current.request_kind == candidate.request_kind - && current.provider_config_fingerprint == candidate.provider_config_fingerprint - && current.allow_idle_context_compaction == candidate.allow_idle_context_compaction -} - -fn request_slot_for_attempt(identity: &AgentRuntimeProviderRetryIdentity, attempt: u32) -> String { - if attempt == 0 { - identity.base_request_slot.clone() - } else { - format!("{}-transient-{attempt}", identity.base_request_slot) - } -} - -fn validate_provider_request_id(value: &str) -> Result<(), String> { - validate_short_text( - "providerRequestId", - value, - TOOL_PLAN_HANDOFF_REQUEST_ID_MAX_CHARS, - false, - )?; - let fingerprint = value - .strip_prefix("provider-request-") - .ok_or_else(|| "tool-plan 成功响应交接 providerRequestId 无效".to_string())?; - if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { - return Err("tool-plan 成功响应交接 providerRequestId 无效".to_string()); - } - Ok(()) -} - -fn validate_short_text( - label: &str, - value: &str, - max_chars: usize, - allow_empty: bool, -) -> Result<(), String> { - if (!allow_empty && value.trim().is_empty()) - || value.chars().count() > max_chars - || value.chars().any(char::is_control) - { - return Err(format!("tool-plan 成功响应交接 {label} 无效")); - } - Ok(()) -} - -fn validate_short_metadata( - root: &Path, - label: &str, - value: &str, - max_chars: usize, - allow_empty: bool, -) -> Result<(), String> { - validate_short_text(label, value, max_chars, allow_empty)?; - validate_private_content(root, label, value, true)?; - if is_sensitive_json_key(value) { - return Err(format!("tool-plan 成功响应交接 {label} 包含敏感元数据")); - } - if redact_absolute_path_tokens(value) != value { - return Err(format!("tool-plan 成功响应交接 {label} 包含绝对路径")); - } - Ok(()) -} - -#[derive(Default)] -struct DiscoveredToolPlanHandoffLedgers { - primary: Option, - previous: Option, -} - -fn discovered_handoff_ledgers_mut<'a>( - discovered: &'a mut BTreeMap<(String, String), DiscoveredToolPlanHandoffLedgers>, - agent_key: &str, - run_key: &str, -) -> Result<&'a mut DiscoveredToolPlanHandoffLedgers, String> { - if !is_handoff_path_key(run_key) { - return Err(format!( - "tool-plan 成功响应交接 run 文件名不是规范 hash:{run_key}" - )); - } - let key = (agent_key.to_string(), run_key.to_string()); - if !discovered.contains_key(&key) - && discovered.len() >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS - { - return Err(format!( - "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS} 条账本上限" - )); - } - Ok(discovered.entry(key).or_default()) -} - -#[cfg(unix)] -fn unix_tool_plan_component(value: &str, label: &str) -> Result { - if value.is_empty() || value == "." || value == ".." || value.contains('/') { - return Err(format!("{label} 名称无效")); - } - CString::new(value.as_bytes()).map_err(|_| format!("{label} 名称包含 NUL")) -} - -#[cfg(unix)] -fn validate_unix_tool_plan_directory_handle(file: &File, label: &str) -> Result<(), String> { - let metadata = file - .metadata() - .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; - // SAFETY: geteuid takes no arguments and has no memory safety preconditions. - let effective_user_id = unsafe { libc::geteuid() }; - if !metadata.is_dir() || metadata.uid() != effective_user_id { - return Err(format!("{label} 必须是当前用户持有的普通目录")); - } - Ok(()) -} - -#[cfg(unix)] -fn validate_unix_tool_plan_file_handle(file: &File, label: &str) -> Result<(), String> { - let metadata = file - .metadata() - .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; - // SAFETY: geteuid takes no arguments and has no memory safety preconditions. - let effective_user_id = unsafe { libc::geteuid() }; - if !metadata.is_file() - || metadata.uid() != effective_user_id - || metadata.nlink() != 1 - || metadata.permissions().mode() & 0o777 != 0o600 - { - return Err(format!("{label} 必须是当前用户持有的 0600 单链接普通文件")); - } - Ok(()) -} - -#[cfg(unix)] -fn verify_unix_tool_plan_root(root: &Path, opened: &File) -> Result<(), String> { - let path_metadata = fs::symlink_metadata(root) - .map_err(|error| format!("复核 tool-plan 项目根目录失败:{error}"))?; - let opened_metadata = opened - .metadata() - .map_err(|error| format!("复核 tool-plan 项目根目录句柄失败:{error}"))?; - if path_metadata.file_type().is_symlink() - || !path_metadata.is_dir() - || path_metadata.dev() != opened_metadata.dev() - || path_metadata.ino() != opened_metadata.ino() - { - return Err("tool-plan 项目根目录在安全扫描期间发生替换".to_string()); - } - Ok(()) -} - -#[cfg(unix)] -fn verify_unix_tool_plan_entry( - parent: &File, - name: &str, - opened: &File, - directory: bool, - label: &str, -) -> Result<(), String> { - let name = unix_tool_plan_component(name, label)?; - // SAFETY: stat is plain data and fstatat initializes it on success. - let mut stat = unsafe { std::mem::zeroed::() }; - // SAFETY: parent and name remain valid for the duration of fstatat. - if unsafe { - libc::fstatat( - parent.as_raw_fd(), - name.as_ptr(), - &mut stat, - libc::AT_SYMLINK_NOFOLLOW, - ) - } != 0 - { - return Err(format!( - "复核 {label} 目录项失败:{}", - std::io::Error::last_os_error() - )); - } - let opened_metadata = opened - .metadata() - .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; - let expected_type = if directory { - libc::S_IFDIR - } else { - libc::S_IFREG - }; - if stat.st_dev != opened_metadata.dev() - || stat.st_ino != opened_metadata.ino() - || stat.st_mode & libc::S_IFMT != expected_type - { - return Err(format!("{label} 在安全扫描期间发生替换")); - } - Ok(()) -} - -#[cfg(unix)] -fn lock_unix_tool_plan_directory(directory: &File, label: &str) -> Result<(), String> { - // SAFETY: flock operates on the live directory fd and is released when File is dropped. - if unsafe { libc::flock(directory.as_raw_fd(), libc::LOCK_EX) } != 0 { - return Err(format!( - "锁定 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(unix)] -fn open_unix_tool_plan_root(root: &Path) -> Result { - let root_name = CString::new(root.as_os_str().as_bytes()) - .map_err(|_| "tool-plan 项目根目录包含 NUL".to_string())?; - // SAFETY: root_name is NUL terminated and a successful fd is transferred to File once. - let fd = unsafe { - libc::open( - root_name.as_ptr(), - libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, - ) - }; - if fd < 0 { - return Err(format!( - "安全打开 tool-plan 项目根目录失败:{}", - std::io::Error::last_os_error() - )); - } - // SAFETY: fd is owned and transferred exactly once. - let file = unsafe { File::from_raw_fd(fd) }; - validate_unix_tool_plan_directory_handle(&file, "tool-plan 项目根目录")?; - verify_unix_tool_plan_root(root, &file)?; - Ok(file) -} - -#[cfg(unix)] -fn open_unix_tool_plan_directory_at( - parent: &File, - name: &str, - label: &str, -) -> Result, String> { - let name_c = unix_tool_plan_component(name, label)?; - let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; - // SAFETY: parent fd and component remain valid during openat. - let fd = unsafe { libc::openat(parent.as_raw_fd(), name_c.as_ptr(), flags) }; - if fd < 0 { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(libc::ENOENT) { - return Ok(None); - } - return Err(format!("安全打开 {label} 失败:{error}")); - } - // SAFETY: fd is owned and transferred exactly once. - let file = unsafe { File::from_raw_fd(fd) }; - validate_unix_tool_plan_directory_handle(&file, label)?; - verify_unix_tool_plan_entry(parent, name, &file, true, label)?; - Ok(Some(file)) -} - -#[cfg(unix)] -fn open_or_create_unix_tool_plan_directory_at( - parent: &File, - name: &str, - label: &str, -) -> Result { - if let Some(directory) = open_unix_tool_plan_directory_at(parent, name, label)? { - return Ok(directory); - } - let name_c = unix_tool_plan_component(name, label)?; - // SAFETY: parent is a stable directory fd and name is a validated relative component. - if unsafe { libc::mkdirat(parent.as_raw_fd(), name_c.as_ptr(), 0o700) } != 0 { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() != Some(libc::EEXIST) { - return Err(format!("创建 {label} 失败:{error}")); - } - } else { - parent - .sync_all() - .map_err(|error| format!("同步 {label} 父目录失败:{error}"))?; - } - open_unix_tool_plan_directory_at(parent, name, label)? - .ok_or_else(|| format!("创建后重新打开 {label} 失败")) -} - -#[cfg(unix)] -fn try_open_unix_tool_plan_file_at( - parent: &File, - name: &str, - label: &str, -) -> Result, String> { - let name_c = unix_tool_plan_component(name, label)?; - // SAFETY: parent fd and component remain valid during openat. - let fd = unsafe { - libc::openat( - parent.as_raw_fd(), - name_c.as_ptr(), - libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, - ) - }; - if fd < 0 { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(libc::ENOENT) { - return Ok(None); - } - return Err(format!("安全打开 {label} 失败:{error}")); - } - // SAFETY: fd is owned and transferred exactly once. - let file = unsafe { File::from_raw_fd(fd) }; - validate_unix_tool_plan_file_handle(&file, label)?; - verify_unix_tool_plan_entry(parent, name, &file, false, label)?; - Ok(Some(file)) -} - -#[cfg(unix)] -fn open_unix_tool_plan_file_for_removal_at( - parent: &File, - name: &str, - label: &str, -) -> Result, String> { - let name_c = unix_tool_plan_component(name, label)?; - // SAFETY: parent fd and component remain valid during openat. - let fd = unsafe { - libc::openat( - parent.as_raw_fd(), - name_c.as_ptr(), - libc::O_RDWR | libc::O_NOFOLLOW | libc::O_CLOEXEC, - ) - }; - if fd < 0 { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(libc::ENOENT) { - return Ok(None); - } - return Err(format!("安全打开待隔离 {label} 失败:{error}")); - } - // SAFETY: fd is owned and transferred exactly once. - let file = unsafe { File::from_raw_fd(fd) }; - validate_unix_tool_plan_file_handle(&file, label)?; - verify_unix_tool_plan_entry(parent, name, &file, false, label)?; - Ok(Some(file)) -} - -#[cfg(unix)] -struct UnixToolPlanDirectoryStream(*mut libc::DIR); - -#[cfg(unix)] -impl Drop for UnixToolPlanDirectoryStream { - fn drop(&mut self) { - // SAFETY: this guard owns the DIR pointer returned by fdopendir. - unsafe { - libc::closedir(self.0); - } - } -} - -#[cfg(unix)] -fn read_unix_tool_plan_directory_names( - directory: &File, - label: &str, -) -> Result, String> { - // SAFETY: fcntl duplicates the live directory fd and returns independent ownership. - let duplicated = unsafe { libc::fcntl(directory.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; - if duplicated < 0 { - return Err(format!( - "复制 {label} 目录句柄失败:{}", - std::io::Error::last_os_error() - )); - } - // SAFETY: duplicated is an owned directory fd; fdopendir takes ownership on success. - let stream = unsafe { libc::fdopendir(duplicated) }; - if stream.is_null() { - let error = std::io::Error::last_os_error(); - // SAFETY: fdopendir failed, so duplicated remains owned here. - unsafe { - libc::close(duplicated); - } - return Err(format!("读取 {label} 目录失败:{error}")); - } - let stream = UnixToolPlanDirectoryStream(stream); - let mut names = Vec::new(); - loop { - // SAFETY: stream owns a valid DIR pointer for the duration of this loop. - let entry = unsafe { libc::readdir(stream.0) }; - if entry.is_null() { - break; - } - // SAFETY: d_name is NUL terminated for a successful readdir entry. - let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; - let name = std::str::from_utf8(name.to_bytes()) - .map_err(|_| format!("{label} 目录项名称必须是 UTF-8"))?; - if matches!(name, "." | "..") { - continue; - } - names.push(name.to_string()); - } - Ok(names) -} - -#[cfg(unix)] -fn read_unix_discovered_ledger_file( - root: &Path, - parent: &File, - file_name: &str, - agent_key: &str, - run_key: &str, -) -> Result { - try_read_unix_discovered_ledger_file(root, parent, file_name, agent_key, run_key)? - .ok_or_else(|| "tool-plan 成功响应交接账本文件在安全扫描期间消失".to_string()) -} - -#[cfg(unix)] -fn try_read_unix_discovered_ledger_file( - root: &Path, - parent: &File, - file_name: &str, - agent_key: &str, - run_key: &str, -) -> Result, String> { - let Some(mut file) = - try_open_unix_tool_plan_file_at(parent, file_name, "tool-plan 成功响应交接账本文件")? - else { - return Ok(None); - }; - let metadata = file - .metadata() - .map_err(|error| format!("读取 tool-plan 成功响应交接账本元数据失败:{error}"))?; - if metadata.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES as u64 { - return Err(format!( - "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" - )); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - std::io::Read::by_ref(&mut file) - .take((TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|error| format!("读取 tool-plan 成功响应交接账本失败:{error}"))?; - if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { - return Err(format!( - "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" - )); - } - verify_unix_tool_plan_entry( - parent, - file_name, - &file, - false, - "tool-plan 成功响应交接账本文件", - )?; - let ledger = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 tool-plan 成功响应交接账本失败:{error}"))?; - validate_ledger(root, &ledger)?; - if path_key(&ledger.agent_id) != agent_key || path_key(&ledger.run_id) != run_key { - return Err("tool-plan 成功响应交接 hash 路径与 Agent/run 身份冲突".to_string()); - } - Ok(Some(ledger)) -} - -#[cfg(unix)] -struct UnixToolPlanAgentStorage { - project_directory: File, - agent_runtime_directory: File, - runtime_directory: File, - handoff_directory: File, - agent_directory: File, - agent_key: String, -} - -#[cfg(unix)] -impl UnixToolPlanAgentStorage { - fn verify(&self, root: &Path) -> Result<(), String> { - verify_unix_tool_plan_entry( - &self.handoff_directory, - &self.agent_key, - &self.agent_directory, - true, - "tool-plan 成功响应交接 Agent 目录", - )?; - verify_unix_tool_plan_entry( - &self.runtime_directory, - "tool-plan-handoffs", - &self.handoff_directory, - true, - "tool-plan 成功响应交接根目录", - )?; - verify_unix_tool_plan_entry( - &self.agent_runtime_directory, - "runtime", - &self.runtime_directory, - true, - "Agent Runtime 目录", - )?; - verify_unix_tool_plan_entry( - &self.project_directory, - ".agent", - &self.agent_runtime_directory, - true, - "项目 .agent 目录", - )?; - verify_unix_tool_plan_root(root, &self.project_directory) - } -} - -#[cfg(unix)] -fn open_unix_tool_plan_agent_storage( - root: &Path, - agent_id: &str, - create: bool, -) -> Result, String> { - let project_directory = open_unix_tool_plan_root(root)?; - let agent_runtime_directory = if create { - open_or_create_unix_tool_plan_directory_at( - &project_directory, - ".agent", - "项目 .agent 目录", - )? - } else { - let Some(directory) = - open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? - else { - return Ok(None); - }; - directory - }; - let runtime_directory = if create { - open_or_create_unix_tool_plan_directory_at( - &agent_runtime_directory, - "runtime", - "Agent Runtime 目录", - )? - } else { - let Some(directory) = open_unix_tool_plan_directory_at( - &agent_runtime_directory, - "runtime", - "Agent Runtime 目录", - )? - else { - return Ok(None); - }; - directory - }; - let handoff_directory = if create { - open_or_create_unix_tool_plan_directory_at( - &runtime_directory, - "tool-plan-handoffs", - "tool-plan 成功响应交接根目录", - )? - } else { - let Some(directory) = open_unix_tool_plan_directory_at( - &runtime_directory, - "tool-plan-handoffs", - "tool-plan 成功响应交接根目录", - )? - else { - return Ok(None); - }; - directory - }; - lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; - let agent_key = path_key(agent_id); - let agent_directory = if create { - open_or_create_unix_tool_plan_directory_at( - &handoff_directory, - &agent_key, - "tool-plan 成功响应交接 Agent 目录", - )? - } else { - let Some(directory) = open_unix_tool_plan_directory_at( - &handoff_directory, - &agent_key, - "tool-plan 成功响应交接 Agent 目录", - )? - else { - return Ok(None); - }; - directory - }; - let storage = UnixToolPlanAgentStorage { - project_directory, - agent_runtime_directory, - runtime_directory, - handoff_directory, - agent_directory, - agent_key, - }; - storage.verify(root)?; - Ok(Some(storage)) -} - -#[cfg(unix)] -fn read_for_run_at_unix( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result, String> { - read_for_run_at_unix_with_agent_open_hook(root, agent_id, run_id, |_| {}) -} - -#[cfg(unix)] -fn read_for_run_at_unix_with_agent_open_hook( - root: &Path, - agent_id: &str, - run_id: &str, - after_agent_open: F, -) -> Result, String> -where - F: FnOnce(&str), -{ - let Some(storage) = open_unix_tool_plan_agent_storage(root, agent_id, false)? else { - return Ok(None); - }; - lock_unix_tool_plan_directory( - &storage.agent_directory, - "tool-plan 成功响应交接 Agent 目录", - )?; - after_agent_open(&storage.agent_key); - let run_key = path_key(run_id); - let primary_name = format!("{run_key}.json"); - let previous_name = format!(".{run_key}.json.previous"); - let primary = try_read_unix_discovered_ledger_file( - root, - &storage.agent_directory, - &primary_name, - &storage.agent_key, - &run_key, - )?; - let previous = try_read_unix_discovered_ledger_file( - root, - &storage.agent_directory, - &previous_name, - &storage.agent_key, - &run_key, - )?; - storage.verify(root)?; - let selected = select_primary_and_previous(&storage.agent_key, &run_key, primary, previous)?; - if selected - .as_ref() - .is_some_and(|ledger| ledger.agent_id != agent_id || ledger.run_id != run_id) - { - return Err("tool-plan 成功响应交接账本与路径 Agent/run 身份冲突".to_string()); - } - Ok(selected) -} - -#[cfg(unix)] -fn create_unix_tool_plan_temp_file_at( - parent: &File, - run_key: &str, -) -> Result<(String, File), String> { - for _ in 0..32 { - let file_name = format!( - ".{run_key}.json.tmp.{}.{}", - std::process::id(), - next_tool_plan_temp_nonce() - ); - let name = unix_tool_plan_component(&file_name, "tool-plan 成功响应交接临时文件")?; - // SAFETY: parent is stable, name is relative, and a successful fd is transferred once. - let fd = unsafe { - libc::openat( - parent.as_raw_fd(), - name.as_ptr(), - libc::O_CREAT | libc::O_EXCL | libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, - 0o600, - ) - }; - if fd < 0 { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(libc::EEXIST) { - continue; - } - return Err(format!("创建 tool-plan 成功响应交接临时文件失败:{error}")); - } - // SAFETY: fd is owned and transferred exactly once. - let file = unsafe { File::from_raw_fd(fd) }; - validate_unix_tool_plan_file_handle(&file, "tool-plan 成功响应交接临时文件")?; - // SAFETY: flock operates on the live temp fd and the lock follows the open file. - if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { - return Err(format!( - "锁定 tool-plan 成功响应交接临时文件失败:{}", - std::io::Error::last_os_error() - )); - } - verify_unix_tool_plan_entry( - parent, - &file_name, - &file, - false, - "tool-plan 成功响应交接临时文件", - )?; - return Ok((file_name, file)); - } - Err("创建 tool-plan 成功响应交接临时文件失败:名称冲突".to_string()) -} - -#[cfg(any(target_os = "linux", target_os = "android"))] -fn exchange_unix_tool_plan_entries_at( - parent: &File, - left_name: &str, - right_name: &str, - label: &str, -) -> Result<(), String> { - let left = unix_tool_plan_component(left_name, label)?; - let right = unix_tool_plan_component(right_name, label)?; - // SAFETY: both names are fixed relative components under the same held directory. - if unsafe { - libc::renameat2( - parent.as_raw_fd(), - left.as_ptr(), - parent.as_raw_fd(), - right.as_ptr(), - libc::RENAME_EXCHANGE, - ) - } != 0 - { - return Err(format!( - "原子交换 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(target_vendor = "apple")] -fn exchange_unix_tool_plan_entries_at( - parent: &File, - left_name: &str, - right_name: &str, - label: &str, -) -> Result<(), String> { - let left = unix_tool_plan_component(left_name, label)?; - let right = unix_tool_plan_component(right_name, label)?; - // SAFETY: both names are fixed relative components under the same held directory. - if unsafe { - libc::renameatx_np( - parent.as_raw_fd(), - left.as_ptr(), - parent.as_raw_fd(), - right.as_ptr(), - libc::RENAME_SWAP, - ) - } != 0 - { - return Err(format!( - "原子交换 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(all( - unix, - not(any(target_os = "linux", target_os = "android")), - not(target_vendor = "apple") -))] -fn exchange_unix_tool_plan_entries_at( - _parent: &File, - _left_name: &str, - _right_name: &str, - _label: &str, -) -> Result<(), String> { - Err("当前 Unix 平台不支持安全原子交换 tool-plan 账本".to_string()) -} - -#[cfg(any(target_os = "linux", target_os = "android"))] -fn rename_unix_tool_plan_entry_noreplace_at( - parent: &File, - source_name: &str, - target_name: &str, - label: &str, -) -> Result<(), String> { - let source = unix_tool_plan_component(source_name, label)?; - let target = unix_tool_plan_component(target_name, label)?; - // SAFETY: both names are fixed relative components under the same held directory. - if unsafe { - libc::renameat2( - parent.as_raw_fd(), - source.as_ptr(), - parent.as_raw_fd(), - target.as_ptr(), - libc::RENAME_NOREPLACE, - ) - } != 0 - { - return Err(format!( - "隔离 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(target_vendor = "apple")] -fn rename_unix_tool_plan_entry_noreplace_at( - parent: &File, - source_name: &str, - target_name: &str, - label: &str, -) -> Result<(), String> { - let source = unix_tool_plan_component(source_name, label)?; - let target = unix_tool_plan_component(target_name, label)?; - // SAFETY: both names are fixed relative components under the same held directory. - if unsafe { - libc::renameatx_np( - parent.as_raw_fd(), - source.as_ptr(), - parent.as_raw_fd(), - target.as_ptr(), - libc::RENAME_EXCL, - ) - } != 0 - { - return Err(format!( - "隔离 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(all( - unix, - not(any(target_os = "linux", target_os = "android")), - not(target_vendor = "apple") -))] -fn rename_unix_tool_plan_entry_noreplace_at( - _parent: &File, - _source_name: &str, - _target_name: &str, - _label: &str, -) -> Result<(), String> { - Err("当前 Unix 平台不支持安全隔离 tool-plan 文件".to_string()) -} - -#[cfg(unix)] -fn rename_unix_tool_plan_entry_at( - parent: &File, - source_name: &str, - target_name: &str, - label: &str, -) -> Result<(), String> { - let source = unix_tool_plan_component(source_name, label)?; - let target = unix_tool_plan_component(target_name, label)?; - // SAFETY: both names are fixed relative components under the same held directory. - if unsafe { - libc::renameat( - parent.as_raw_fd(), - source.as_ptr(), - parent.as_raw_fd(), - target.as_ptr(), - ) - } != 0 - { - return Err(format!( - "重命名 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(unix)] -fn write_ledger_at_unix( - root: &Path, - ledger: &AgentRuntimeToolPlanHandoffLedger, - bytes: &[u8], -) -> Result<(), String> { - write_ledger_at_unix_with_hooks(root, ledger, bytes, |_| {}, |_| {}) -} - -#[cfg(all(unix, test))] -fn write_ledger_at_unix_with_agent_open_hook( - root: &Path, - ledger: &AgentRuntimeToolPlanHandoffLedger, - bytes: &[u8], - after_agent_open: F, -) -> Result<(), String> -where - F: FnOnce(&str), -{ - write_ledger_at_unix_with_hooks(root, ledger, bytes, after_agent_open, |_| {}) -} - -#[cfg(unix)] -fn write_ledger_at_unix_with_hooks( - root: &Path, - ledger: &AgentRuntimeToolPlanHandoffLedger, - bytes: &[u8], - after_agent_open: F, - before_install: G, -) -> Result<(), String> -where - F: FnOnce(&str), - G: FnOnce(&str), -{ - let storage = open_unix_tool_plan_agent_storage(root, &ledger.agent_id, true)? - .ok_or_else(|| "创建 tool-plan 成功响应交接存储目录失败".to_string())?; - lock_unix_tool_plan_directory( - &storage.agent_directory, - "tool-plan 成功响应交接 Agent 目录", - )?; - after_agent_open(&storage.agent_key); - let run_key = path_key(&ledger.run_id); - let primary_name = format!("{run_key}.json"); - let previous_name = format!(".{run_key}.json.previous"); - let (temporary_name, mut temporary_file) = - create_unix_tool_plan_temp_file_at(&storage.agent_directory, &run_key)?; - if let Err(error) = temporary_file - .write_all(bytes) - .and_then(|_| temporary_file.sync_data()) - { - let _ = remove_unix_tool_plan_file_at( - &storage.agent_directory, - &temporary_name, - "tool-plan 成功响应交接临时文件", - ); - return Err(format!("写入 tool-plan 成功响应交接临时文件失败:{error}")); - } - verify_unix_tool_plan_entry( - &storage.agent_directory, - &temporary_name, - &temporary_file, - false, - "tool-plan 成功响应交接临时文件", - )?; - let previous_primary = try_open_unix_tool_plan_file_at( - &storage.agent_directory, - &primary_name, - "tool-plan 成功响应交接原账本", - )?; - remove_unix_tool_plan_file_at( - &storage.agent_directory, - &previous_name, - "tool-plan 成功响应交接恢复副本", - )?; - before_install(&temporary_name); - if let Some(previous_primary) = previous_primary { - exchange_unix_tool_plan_entries_at( - &storage.agent_directory, - &temporary_name, - &primary_name, - "tool-plan 成功响应交接账本", - )?; - let installed = verify_unix_tool_plan_entry( - &storage.agent_directory, - &primary_name, - &temporary_file, - false, - "tool-plan 成功响应交接新账本", - ) - .and_then(|_| { - verify_unix_tool_plan_entry( - &storage.agent_directory, - &temporary_name, - &previous_primary, - false, - "tool-plan 成功响应交接原账本", - ) - }); - if let Err(install_error) = installed { - let rollback = exchange_unix_tool_plan_entries_at( - &storage.agent_directory, - &temporary_name, - &primary_name, - "tool-plan 成功响应交接账本回滚", - ) - .and_then(|_| { - verify_unix_tool_plan_entry( - &storage.agent_directory, - &primary_name, - &previous_primary, - false, - "tool-plan 成功响应交接原账本回滚", - ) - }); - return Err(match rollback { - Ok(()) => format!("安装 tool-plan 成功响应交接账本身份冲突:{install_error}"), - Err(rollback_error) => format!( - "安装 tool-plan 成功响应交接账本身份冲突且回滚失败:{install_error}; {rollback_error}" - ), - }); - } - rename_unix_tool_plan_entry_at( - &storage.agent_directory, - &temporary_name, - &previous_name, - "tool-plan 成功响应交接恢复副本", - )?; - verify_unix_tool_plan_entry( - &storage.agent_directory, - &previous_name, - &previous_primary, - false, - "tool-plan 成功响应交接恢复副本", - )?; - } else { - verify_unix_tool_plan_entry( - &storage.agent_directory, - &temporary_name, - &temporary_file, - false, - "tool-plan 成功响应交接临时文件", - )?; - rename_unix_tool_plan_entry_at( - &storage.agent_directory, - &temporary_name, - &primary_name, - "tool-plan 成功响应交接账本", - )?; - verify_unix_tool_plan_entry( - &storage.agent_directory, - &primary_name, - &temporary_file, - false, - "tool-plan 成功响应交接新账本", - )?; - } - storage - .agent_directory - .sync_all() - .map_err(|error| format!("同步 tool-plan 成功响应交接目录失败:{error}"))?; - storage.verify(root)?; - drop(temporary_file); - Ok(()) -} - -#[cfg(unix)] -fn try_lock_unix_tool_plan_temp(file: &File) -> Result { - // SAFETY: flock observes only the live temp file descriptor. - if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { - return Ok(true); - } - let error = std::io::Error::last_os_error(); - if error.kind() == std::io::ErrorKind::WouldBlock { - return Ok(false); - } - Err(format!( - "确认 tool-plan 成功响应交接临时文件锁失败:{error}" - )) -} - -#[cfg(unix)] -fn remove_stale_unix_handoff_temp_file_at(parent: &File, file_name: &str) -> Result<(), String> { - remove_stale_unix_handoff_temp_file_at_with_hook(parent, file_name, |_| {}) -} - -#[cfg(unix)] -fn remove_stale_unix_handoff_temp_file_at_with_hook( - parent: &File, - file_name: &str, - after_lock: F, -) -> Result<(), String> -where - F: FnOnce(&str), -{ - let file = open_unix_tool_plan_file_for_removal_at( - parent, - file_name, - "tool-plan 成功响应交接原子临时文件", - )? - .ok_or_else(|| "tool-plan 成功响应交接原子临时文件在清理前消失".to_string())?; - if !try_lock_unix_tool_plan_temp(&file)? { - return Err("tool-plan 成功响应交接原子临时文件仍由活跃写入句柄持有".to_string()); - } - verify_unix_tool_plan_entry( - parent, - file_name, - &file, - false, - "tool-plan 成功响应交接原子临时文件", - )?; - after_lock(file_name); - quarantine_and_remove_unix_tool_plan_file_at( - parent, - file_name, - &file, - "tool-plan 成功响应交接原子临时文件", - )?; - parent - .sync_all() - .map_err(|error| format!("同步 tool-plan 成功响应交接临时文件目录失败:{error}"))?; - Ok(()) -} - -#[cfg(unix)] -fn remove_at_unix_with_agent_open_hook( - root: &Path, - agent_id: &str, - run_id: &str, - mut after_agent_open: F, -) -> Result<(), String> -where - F: FnMut(&str), -{ - let project_directory = open_unix_tool_plan_root(root)?; - let Some(agent_runtime_directory) = - open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? - else { - return Ok(()); - }; - let Some(runtime_directory) = open_unix_tool_plan_directory_at( - &agent_runtime_directory, - "runtime", - "Agent Runtime 目录", - )? - else { - return Ok(()); - }; - let Some(handoff_directory) = open_unix_tool_plan_directory_at( - &runtime_directory, - "tool-plan-handoffs", - "tool-plan 成功响应交接根目录", - )? - else { - return Ok(()); - }; - lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; - let agent_key = path_key(agent_id); - let Some(agent_directory) = open_unix_tool_plan_directory_at( - &handoff_directory, - &agent_key, - "tool-plan 成功响应交接 Agent 目录", - )? - else { - return Ok(()); - }; - lock_unix_tool_plan_directory(&agent_directory, "tool-plan 成功响应交接 Agent 目录")?; - after_agent_open(&agent_key); - let run_key = path_key(run_id); - let primary_name = format!("{run_key}.json"); - let previous_name = format!(".{run_key}.json.previous"); - let removed_previous = remove_unix_tool_plan_file_at( - &agent_directory, - &previous_name, - "tool-plan 成功响应交接恢复副本", - )?; - let removed_primary = remove_unix_tool_plan_file_at( - &agent_directory, - &primary_name, - "tool-plan 成功响应交接账本", - )?; - if removed_previous || removed_primary { - agent_directory - .sync_all() - .map_err(|error| format!("同步 tool-plan 成功响应交接删除目录失败:{error}"))?; - } - verify_unix_tool_plan_entry( - &handoff_directory, - &agent_key, - &agent_directory, - true, - "tool-plan 成功响应交接 Agent 目录", - )?; - verify_unix_tool_plan_entry( - &runtime_directory, - "tool-plan-handoffs", - &handoff_directory, - true, - "tool-plan 成功响应交接根目录", - )?; - verify_unix_tool_plan_entry( - &agent_runtime_directory, - "runtime", - &runtime_directory, - true, - "Agent Runtime 目录", - )?; - verify_unix_tool_plan_entry( - &project_directory, - ".agent", - &agent_runtime_directory, - true, - "项目 .agent 目录", - )?; - verify_unix_tool_plan_root(root, &project_directory) -} - -#[cfg(unix)] -fn remove_unix_tool_plan_file_at( - parent: &File, - file_name: &str, - label: &str, -) -> Result { - remove_unix_tool_plan_file_at_with_hook(parent, file_name, label, |_| {}) -} - -#[cfg(unix)] -fn remove_unix_tool_plan_file_at_with_hook( - parent: &File, - file_name: &str, - label: &str, - after_open: F, -) -> Result -where - F: FnOnce(&str), -{ - let Some(file) = open_unix_tool_plan_file_for_removal_at(parent, file_name, label)? else { - return Ok(false); - }; - verify_unix_tool_plan_entry(parent, file_name, &file, false, label)?; - after_open(file_name); - quarantine_and_remove_unix_tool_plan_file_at(parent, file_name, &file, label)?; - Ok(true) -} - -#[cfg(unix)] -fn quarantine_and_remove_unix_tool_plan_file_at( - parent: &File, - file_name: &str, - file: &File, - label: &str, -) -> Result<(), String> { - let run_key = match classify_handoff_file_name(file_name) { - Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) - | Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) - | Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => run_key, - None => return Err(format!("{label} 文件名无法生成安全隔离名称")), - }; - let quarantine_name = format!( - ".{run_key}.json.tmp.{}.{}", - std::process::id(), - next_tool_plan_temp_nonce() - ); - rename_unix_tool_plan_entry_noreplace_at(parent, file_name, &quarantine_name, label)?; - let quarantined = match open_unix_tool_plan_file_for_removal_at(parent, &quarantine_name, label) - { - Ok(Some(quarantined)) => quarantined, - Ok(None) => { - return Err(format!("{label} 隔离后消失,保留现场等待 reconciliation")); - } - Err(error) => { - let rollback = rename_unix_tool_plan_entry_noreplace_at( - parent, - &quarantine_name, - file_name, - &format!("{label} 隔离回滚"), - ); - return Err(match rollback { - Ok(()) => format!("{label} 隔离对象无效且已回滚:{error}"), - Err(rollback_error) => { - format!("{label} 隔离对象无效且回滚失败:{error}; {rollback_error}") - } - }); - } - }; - let matches_opened = { - let opened_metadata = file - .metadata() - .map_err(|error| format!("复核 {label} 原句柄失败:{error}"))?; - let quarantined_metadata = quarantined - .metadata() - .map_err(|error| format!("复核 {label} 隔离句柄失败:{error}"))?; - opened_metadata.dev() == quarantined_metadata.dev() - && opened_metadata.ino() == quarantined_metadata.ino() - }; - if !matches_opened { - let rollback = rename_unix_tool_plan_entry_noreplace_at( - parent, - &quarantine_name, - file_name, - &format!("{label} 名称换绑回滚"), - ) - .and_then(|_| { - verify_unix_tool_plan_entry( - parent, - file_name, - &quarantined, - false, - &format!("{label} 名称换绑回滚"), - ) - }); - return Err(match rollback { - Ok(()) => format!("{label} 删除前发生名称换绑,替换对象已回滚"), - Err(rollback_error) => { - format!("{label} 删除前发生名称换绑且回滚失败,已保留隔离对象:{rollback_error}") - } - }); - } - file.set_len(0) - .and_then(|_| file.sync_data()) - .map_err(|error| format!("清空并同步已隔离 {label} 失败:{error}"))?; - verify_unix_tool_plan_entry(parent, &quarantine_name, file, false, label)?; - let quarantine = unix_tool_plan_component(&quarantine_name, label)?; - // SAFETY: parent is stable and quarantine is the freshly verified relative component. - if unsafe { libc::unlinkat(parent.as_raw_fd(), quarantine.as_ptr(), 0) } != 0 { - return Err(format!( - "删除已隔离 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - verify_unix_tool_plan_file_unlinked(file, label) -} - -#[cfg(unix)] -fn verify_unix_tool_plan_file_unlinked(file: &File, label: &str) -> Result<(), String> { - let metadata = file - .metadata() - .map_err(|error| format!("复核已删除 {label} 句柄失败:{error}"))?; - if metadata.nlink() != 0 { - return Err(format!("{label} 删除期间发生名称换绑")); - } - Ok(()) -} - -enum DiscoveredToolPlanHandoffFileName<'a> { - Primary(&'a str), - Previous(&'a str), - Temporary { run_key: &'a str }, -} - -fn classify_handoff_file_name(file_name: &str) -> Option> { - if let Some(run_key) = file_name.strip_suffix(".json") { - return Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)); - } - if let Some(run_key) = file_name - .strip_prefix('.') - .and_then(|value| value.strip_suffix(".json.previous")) - { - return Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)); - } - let temporary = file_name.strip_prefix('.')?; - let (run_key, suffix) = temporary.split_once(".json.tmp.")?; - let (pid, nanos) = suffix.split_once('.')?; - if pid.is_empty() - || nanos.is_empty() - || !pid.bytes().all(|byte| byte.is_ascii_digit()) - || !nanos.bytes().all(|byte| byte.is_ascii_digit()) - { - return None; - } - if pid.parse::().ok()? == 0 { - return None; - } - Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) -} - -#[cfg(windows)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WindowsToolPlanFileIdentity { - volume_serial_number: u32, - file_index: u64, - number_of_links: u32, - file_attributes: u32, -} - -#[cfg(windows)] -#[derive(Clone, Copy)] -enum WindowsToolPlanOpenDisposition { - Existing, - OpenOrCreate, - CreateNew, -} - -#[cfg(windows)] -fn windows_tool_plan_file_identity(file: &File) -> Result { - use std::ffi::c_void; - use std::os::windows::io::AsRawHandle; - - #[repr(C)] - struct FileTime { - low_date_time: u32, - high_date_time: u32, - } - #[repr(C)] - struct ByHandleFileInformation { - file_attributes: u32, - creation_time: FileTime, - last_access_time: FileTime, - last_write_time: FileTime, - volume_serial_number: u32, - file_size_high: u32, - file_size_low: u32, - number_of_links: u32, - file_index_high: u32, - file_index_low: u32, - } - #[link(name = "kernel32")] - unsafe extern "system" { - fn GetFileInformationByHandle( - file: *mut c_void, - information: *mut ByHandleFileInformation, - ) -> i32; - } - - // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. - let mut information = unsafe { std::mem::zeroed::() }; - // SAFETY: file owns a live handle and information is a valid output pointer. - if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 { - return Err(format!( - "读取 Windows tool-plan 文件句柄身份失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(WindowsToolPlanFileIdentity { - volume_serial_number: information.volume_serial_number, - file_index: (u64::from(information.file_index_high) << 32) - | u64::from(information.file_index_low), - number_of_links: information.number_of_links, - file_attributes: information.file_attributes, - }) -} - -#[cfg(windows)] -fn validate_windows_tool_plan_directory_handle(file: &File, label: &str) -> Result<(), String> { - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - let metadata = file - .metadata() - .map_err(|error| format!("读取 {label} 句柄元数据失败:{error}"))?; - let identity = windows_tool_plan_file_identity(file)?; - if !metadata.is_dir() || identity.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return Err(format!( - "{label} 必须是普通目录且不能是 Windows junction/reparse point" - )); - } - Ok(()) -} - -#[cfg(windows)] -fn validate_windows_tool_plan_file_handle(file: &File, label: &str) -> Result<(), String> { - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - let metadata = file - .metadata() - .map_err(|error| format!("读取 {label} 句柄元数据失败:{error}"))?; - let identity = windows_tool_plan_file_identity(file)?; - if !metadata.is_file() - || identity.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 - || identity.number_of_links != 1 - { - return Err(format!("{label} 必须是无 reparse point 的单链接普通文件")); - } - Ok(()) -} - -#[cfg(windows)] -fn read_windows_tool_plan_directory_names( - directory: &File, - label: &str, - max_names: usize, -) -> Result, String> { - use std::ffi::c_void; - use std::mem::{offset_of, size_of}; - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::{ - Foundation::ERROR_NO_MORE_FILES, - Storage::FileSystem::{ - FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, GetFileInformationByHandleEx, - FILE_ID_BOTH_DIR_INFO, - }, - }; - - const BUFFER_SIZE: usize = 64 * 1024; - const NAME_OFFSET: usize = offset_of!(FILE_ID_BOTH_DIR_INFO, FileName); - const DOT: u16 = b'.' as u16; - - #[repr(align(8))] - struct DirectoryBuffer([u8; BUFFER_SIZE]); - - let mut buffer = Box::new(DirectoryBuffer([0; BUFFER_SIZE])); - let base = buffer.0.as_mut_ptr(); - let malformed = || format!("{label} 返回了无效的 Windows 目录枚举缓冲区"); - let mut restart = true; - let mut names = Vec::new(); - loop { - let information_class = if restart { - restart = false; - FileIdBothDirectoryRestartInfo - } else { - FileIdBothDirectoryInfo - }; - // SAFETY: directory is a live directory handle and buffer is aligned and writable. - let succeeded = unsafe { - GetFileInformationByHandleEx( - directory.as_raw_handle().cast(), - information_class, - base.cast::(), - BUFFER_SIZE as u32, - ) - }; - if succeeded == 0 { - let error = std::io::Error::last_os_error(); - if error.raw_os_error() == Some(ERROR_NO_MORE_FILES as i32) { - break; - } - return Err(format!("按句柄读取 {label} 失败:{error}")); - } - - let mut offset = 0usize; - loop { - let remaining = BUFFER_SIZE.checked_sub(offset).ok_or_else(&malformed)?; - if remaining < size_of::() { - return Err(malformed()); - } - // SAFETY: offset is bounds-checked and every entry is required to be 8-byte aligned. - let information = unsafe { &*base.add(offset).cast::() }; - let name_bytes = information.FileNameLength as usize; - let used = NAME_OFFSET.checked_add(name_bytes).ok_or_else(&malformed)?; - let next = information.NextEntryOffset as usize; - if name_bytes == 0 || name_bytes % size_of::() != 0 || used > remaining { - return Err(malformed()); - } - if next != 0 && (next % 8 != 0 || next < used || next > remaining) { - return Err(malformed()); - } - // SAFETY: FileNameLength was checked against the remaining buffer and is UTF-16 bytes. - let wide_name = unsafe { - std::slice::from_raw_parts( - base.add(offset + NAME_OFFSET).cast::(), - name_bytes / size_of::(), - ) - }; - if !matches!(wide_name, [DOT] | [DOT, DOT]) { - if names.len() >= max_names { - return Err(format!("{label} 超过 {max_names} 个目录项上限")); - } - names.push( - String::from_utf16(wide_name) - .map_err(|_| format!("{label} 目录项名称必须是有效 UTF-16"))?, - ); - } - if next == 0 { - break; - } - offset = offset.checked_add(next).ok_or_else(&malformed)?; - } - } - Ok(names) -} - -#[cfg(windows)] -fn open_windows_tool_plan_root(root: &Path, writable: bool) -> Result { - use std::os::windows::fs::OpenOptionsExt; - - const FILE_SHARE_READ: u32 = 0x0000_0001; - const FILE_SHARE_WRITE: u32 = 0x0000_0002; - const FILE_SHARE_DELETE: u32 = 0x0000_0004; - const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - - let file = OpenOptions::new() - .read(true) - .write(writable) - .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(root) - .map_err(|error| format!("安全打开 Windows tool-plan 项目根目录失败:{error}"))?; - validate_windows_tool_plan_directory_handle(&file, "tool-plan 项目根目录")?; - Ok(file) -} - -#[cfg(windows)] -fn nt_open_windows_tool_plan_relative( - parent: &File, - name: &str, - directory: bool, - disposition: WindowsToolPlanOpenDisposition, - writable: bool, - exclusive: bool, - delete_access: bool, -) -> std::io::Result { - use std::ffi::c_void; - use std::os::windows::ffi::OsStrExt; - use std::os::windows::io::{AsRawHandle, FromRawHandle}; - - if name.is_empty() - || matches!(name, "." | "..") - || name.contains('/') - || name.contains('\\') - || name.contains('\0') - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid relative component", - )); - } - - type Handle = *mut c_void; - #[repr(C)] - struct UnicodeString { - length: u16, - maximum_length: u16, - buffer: *mut u16, - } - #[repr(C)] - struct ObjectAttributes { - length: u32, - root_directory: Handle, - object_name: *mut UnicodeString, - attributes: u32, - security_descriptor: *mut c_void, - security_quality_of_service: *mut c_void, - } - #[repr(C)] - struct IoStatusBlock { - status: isize, - information: usize, - } - #[link(name = "ntdll")] - unsafe extern "system" { - fn NtCreateFile( - file_handle: *mut Handle, - desired_access: u32, - object_attributes: *mut ObjectAttributes, - io_status_block: *mut IoStatusBlock, - allocation_size: *mut i64, - file_attributes: u32, - share_access: u32, - create_disposition: u32, - create_options: u32, - ea_buffer: *mut c_void, - ea_length: u32, - ) -> i32; - fn RtlNtStatusToDosError(status: i32) -> u32; - } - - const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; - const FILE_SHARE_READ: u32 = 0x0000_0001; - const FILE_SHARE_WRITE: u32 = 0x0000_0002; - const FILE_SHARE_DELETE: u32 = 0x0000_0004; - const FILE_OPEN: u32 = 0x0000_0001; - const FILE_CREATE: u32 = 0x0000_0002; - const FILE_OPEN_IF: u32 = 0x0000_0003; - const FILE_DIRECTORY_FILE: u32 = 0x0000_0001; - const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; - const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; - const FILE_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080; - const FILE_LIST_DIRECTORY: u32 = 0x0000_0001; - const FILE_ADD_FILE: u32 = 0x0000_0002; - const FILE_ADD_SUBDIRECTORY: u32 = 0x0000_0004; - const FILE_TRAVERSE: u32 = 0x0000_0020; - const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080; - const READ_CONTROL: u32 = 0x0002_0000; - const DELETE: u32 = 0x0001_0000; - const SYNCHRONIZE: u32 = 0x0010_0000; - const GENERIC_READ: u32 = 0x8000_0000; - const GENERIC_WRITE: u32 = 0x4000_0000; - - let mut wide_name = std::ffi::OsStr::new(name).encode_wide().collect::>(); - let byte_length = wide_name - .len() - .checked_mul(2) - .and_then(|length| u16::try_from(length).ok()) - .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "relative name too long") - })?; - let mut unicode_name = UnicodeString { - length: byte_length, - maximum_length: byte_length, - buffer: wide_name.as_mut_ptr(), - }; - let mut attributes = ObjectAttributes { - length: std::mem::size_of::() as u32, - root_directory: parent.as_raw_handle().cast(), - object_name: &mut unicode_name, - attributes: OBJ_CASE_INSENSITIVE, - security_descriptor: std::ptr::null_mut(), - security_quality_of_service: std::ptr::null_mut(), - }; - let mut io_status = IoStatusBlock { - status: 0, - information: 0, - }; - let mut handle = std::ptr::null_mut(); - let mut desired_access = if directory { - FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE - } else { - GENERIC_READ | READ_CONTROL | SYNCHRONIZE - }; - if writable { - desired_access |= if directory { - FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY - } else { - GENERIC_WRITE - }; - } - if delete_access { - desired_access |= DELETE; - } - let create_options = if directory { - FILE_DIRECTORY_FILE - } else { - FILE_NON_DIRECTORY_FILE - } | FILE_SYNCHRONOUS_IO_NONALERT - | FILE_OPEN_REPARSE_POINT; - let create_disposition = match disposition { - WindowsToolPlanOpenDisposition::Existing => FILE_OPEN, - WindowsToolPlanOpenDisposition::OpenOrCreate => FILE_OPEN_IF, - WindowsToolPlanOpenDisposition::CreateNew => FILE_CREATE, - }; - // SAFETY: all NT structures and buffers remain live for this call; handle is an output. - let status = unsafe { - NtCreateFile( - &mut handle, - desired_access, - &mut attributes, - &mut io_status, - std::ptr::null_mut(), - FILE_ATTRIBUTE_NORMAL, - if exclusive { - 0 - } else { - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE - }, - create_disposition, - create_options, - std::ptr::null_mut(), - 0, - ) - }; - if status < 0 || handle.is_null() { - // SAFETY: conversion accepts any NTSTATUS and returns a Win32 error code. - let code = unsafe { RtlNtStatusToDosError(status) }; - return Err(std::io::Error::from_raw_os_error(code as i32)); - } - // SAFETY: NtCreateFile returned an owned handle transferred exactly once to File. - Ok(unsafe { File::from_raw_handle(handle.cast()) }) -} - -#[cfg(windows)] -fn windows_tool_plan_error_is_not_found(error: &std::io::Error) -> bool { - error.kind() == std::io::ErrorKind::NotFound - || matches!(error.raw_os_error(), Some(2) | Some(3)) -} - -#[cfg(windows)] -fn windows_tool_plan_error_is_sharing_violation(error: &std::io::Error) -> bool { - matches!(error.raw_os_error(), Some(32) | Some(33)) -} - -#[cfg(windows)] -fn open_windows_tool_plan_directory_at( - parent: &File, - name: &str, - label: &str, - create: bool, -) -> Result, String> { - let disposition = if create { - WindowsToolPlanOpenDisposition::OpenOrCreate - } else { - WindowsToolPlanOpenDisposition::Existing - }; - let file = match nt_open_windows_tool_plan_relative( - parent, - name, - true, - disposition, - create, - false, - false, - ) { - Ok(file) => file, - Err(error) if !create && windows_tool_plan_error_is_not_found(&error) => return Ok(None), - Err(error) => return Err(format!("安全相对打开 {label} 失败:{error}")), - }; - validate_windows_tool_plan_directory_handle(&file, label)?; - Ok(Some(file)) -} - -#[cfg(windows)] -fn try_open_windows_tool_plan_file_at( - parent: &File, - name: &str, - label: &str, - exclusive: bool, - delete_access: bool, -) -> Result, String> { - let file = match nt_open_windows_tool_plan_relative( - parent, - name, - false, - WindowsToolPlanOpenDisposition::Existing, - false, - exclusive, - delete_access, - ) { - Ok(file) => file, - Err(error) if windows_tool_plan_error_is_not_found(&error) => return Ok(None), - Err(error) if windows_tool_plan_error_is_sharing_violation(&error) => { - return Err(format!("{label} 仍由活跃写入句柄持有")); - } - Err(error) => return Err(format!("安全相对打开 {label} 失败:{error}")), - }; - validate_windows_tool_plan_file_handle(&file, label)?; - Ok(Some(file)) -} - -#[cfg(windows)] -fn verify_windows_tool_plan_entry( - parent: &File, - name: &str, - opened: &File, - directory: bool, - label: &str, -) -> Result<(), String> { - let current = nt_open_windows_tool_plan_relative( - parent, - name, - directory, - WindowsToolPlanOpenDisposition::Existing, - false, - false, - false, - ) - .map_err(|error| format!("复核 {label} 目录项失败:{error}"))?; - if directory { - validate_windows_tool_plan_directory_handle(¤t, label)?; - } else { - validate_windows_tool_plan_file_handle(¤t, label)?; - } - if windows_tool_plan_file_identity(¤t)? != windows_tool_plan_file_identity(opened)? { - return Err(format!("{label} 在安全操作期间发生替换")); - } - Ok(()) -} - -#[cfg(windows)] -struct WindowsToolPlanRootStorage { - root_path: PathBuf, - project_directory: File, - agent_runtime_directory: File, - runtime_directory: File, - handoff_directory: File, -} - -#[cfg(windows)] -impl WindowsToolPlanRootStorage { - fn verify(&self) -> Result<(), String> { - let current_root = open_windows_tool_plan_root(&self.root_path, false)?; - if windows_tool_plan_file_identity(¤t_root)? - != windows_tool_plan_file_identity(&self.project_directory)? - { - return Err("Windows tool-plan 项目根目录在安全操作期间发生替换".to_string()); - } - verify_windows_tool_plan_entry( - &self.project_directory, - ".agent", - &self.agent_runtime_directory, - true, - "项目 .agent 目录", - )?; - verify_windows_tool_plan_entry( - &self.agent_runtime_directory, - "runtime", - &self.runtime_directory, - true, - "Agent Runtime 目录", - )?; - verify_windows_tool_plan_entry( - &self.runtime_directory, - "tool-plan-handoffs", - &self.handoff_directory, - true, - "tool-plan 成功响应交接根目录", - ) - } -} - -#[cfg(windows)] -fn open_windows_tool_plan_root_storage( - root: &Path, - create: bool, -) -> Result, String> { - let project_directory = open_windows_tool_plan_root(root, create)?; - let Some(agent_runtime_directory) = open_windows_tool_plan_directory_at( - &project_directory, - ".agent", - "项目 .agent 目录", - create, - )? - else { - return Ok(None); - }; - let Some(runtime_directory) = open_windows_tool_plan_directory_at( - &agent_runtime_directory, - "runtime", - "Agent Runtime 目录", - create, - )? - else { - return Ok(None); - }; - let Some(handoff_directory) = open_windows_tool_plan_directory_at( - &runtime_directory, - "tool-plan-handoffs", - "tool-plan 成功响应交接根目录", - create, - )? - else { - return Ok(None); - }; - let storage = WindowsToolPlanRootStorage { - root_path: root.to_path_buf(), - project_directory, - agent_runtime_directory, - runtime_directory, - handoff_directory, - }; - storage.verify()?; - Ok(Some(storage)) -} - -#[cfg(windows)] -struct WindowsToolPlanAgentStorage { - root: WindowsToolPlanRootStorage, - agent_directory: File, - agent_key: String, -} - -#[cfg(windows)] -impl WindowsToolPlanAgentStorage { - fn verify(&self) -> Result<(), String> { - self.root.verify()?; - verify_windows_tool_plan_entry( - &self.root.handoff_directory, - &self.agent_key, - &self.agent_directory, - true, - "tool-plan 成功响应交接 Agent 目录", - ) - } -} - -#[cfg(windows)] -fn open_windows_tool_plan_agent_storage( - root: &Path, - agent_id: &str, - create: bool, -) -> Result, String> { - let Some(root_storage) = open_windows_tool_plan_root_storage(root, create)? else { - return Ok(None); - }; - let agent_key = path_key(agent_id); - let Some(agent_directory) = open_windows_tool_plan_directory_at( - &root_storage.handoff_directory, - &agent_key, - "tool-plan 成功响应交接 Agent 目录", - create, - )? - else { - return Ok(None); - }; - let storage = WindowsToolPlanAgentStorage { - root: root_storage, - agent_directory, - agent_key, - }; - storage.verify()?; - Ok(Some(storage)) -} - -#[cfg(windows)] -fn try_read_windows_discovered_ledger_file( - root: &Path, - parent: &File, - file_name: &str, - agent_key: &str, - run_key: &str, -) -> Result, String> { - let Some(mut file) = try_open_windows_tool_plan_file_at( - parent, - file_name, - "tool-plan 成功响应交接账本文件", - false, - false, - )? - else { - return Ok(None); - }; - let metadata = file - .metadata() - .map_err(|error| format!("读取 Windows tool-plan 账本元数据失败:{error}"))?; - if metadata.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES as u64 { - return Err(format!( - "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" - )); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - std::io::Read::by_ref(&mut file) - .take((TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|error| format!("读取 Windows tool-plan 成功响应交接账本失败:{error}"))?; - if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { - return Err(format!( - "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" - )); - } - verify_windows_tool_plan_entry( - parent, - file_name, - &file, - false, - "tool-plan 成功响应交接账本文件", - )?; - let ledger = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 Windows tool-plan 成功响应交接账本失败:{error}"))?; - validate_ledger(root, &ledger)?; - if path_key(&ledger.agent_id) != agent_key || path_key(&ledger.run_id) != run_key { - return Err("tool-plan 成功响应交接 hash 路径与 Agent/run 身份冲突".to_string()); - } - Ok(Some(ledger)) -} - -#[cfg(windows)] -fn read_for_run_at_windows( - root: &Path, - agent_id: &str, - run_id: &str, -) -> Result, String> { - let Some(storage) = open_windows_tool_plan_agent_storage(root, agent_id, false)? else { - return Ok(None); - }; - let run_key = path_key(run_id); - let primary = try_read_windows_discovered_ledger_file( - root, - &storage.agent_directory, - &format!("{run_key}.json"), - &storage.agent_key, - &run_key, - )?; - let previous = try_read_windows_discovered_ledger_file( - root, - &storage.agent_directory, - &format!(".{run_key}.json.previous"), - &storage.agent_key, - &run_key, - )?; - storage.verify()?; - let selected = select_primary_and_previous(&storage.agent_key, &run_key, primary, previous)?; - if selected - .as_ref() - .is_some_and(|ledger| ledger.agent_id != agent_id || ledger.run_id != run_id) - { - return Err("tool-plan 成功响应交接账本与路径 Agent/run 身份冲突".to_string()); - } - Ok(selected) -} - -#[cfg(windows)] -fn set_windows_tool_plan_file_deleted(file: &File, label: &str) -> Result<(), String> { - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, - }; - - let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; - // SAFETY: file owns a DELETE-capable handle and disposition is a valid fixed-size buffer. - if unsafe { - SetFileInformationByHandle( - file.as_raw_handle().cast(), - FileDispositionInfo, - (&disposition as *const FILE_DISPOSITION_INFO).cast(), - std::mem::size_of::() as u32, - ) - } == 0 - { - return Err(format!( - "按句柄删除 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(windows)] -fn rename_windows_tool_plan_file_at( - file: &File, - parent: &File, - new_name: &str, - replace: bool, - label: &str, -) -> Result<(), String> { - use std::os::windows::ffi::OsStrExt; - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - FileRenameInfo, SetFileInformationByHandle, FILE_RENAME_INFO, - }; - - let wide_name = std::ffi::OsStr::new(new_name) - .encode_wide() - .collect::>(); - let name_bytes = wide_name - .len() - .checked_mul(2) - .and_then(|value| u32::try_from(value).ok()) - .ok_or_else(|| format!("{label} 目标名称过长"))?; - let header_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName); - let total_bytes = header_bytes - .checked_add(name_bytes as usize) - .ok_or_else(|| format!("{label} 重命名缓冲区过大"))?; - let word_bytes = std::mem::size_of::(); - let mut buffer = vec![0usize; total_bytes.div_ceil(word_bytes)]; - let information = buffer.as_mut_ptr().cast::(); - // SAFETY: buffer is aligned and sized for the fixed header plus the complete UTF-16 name. - unsafe { - (*information).Anonymous.ReplaceIfExists = replace; - (*information).RootDirectory = parent.as_raw_handle().cast(); - (*information).FileNameLength = name_bytes; - std::ptr::copy_nonoverlapping( - wide_name.as_ptr(), - (*information).FileName.as_mut_ptr(), - wide_name.len(), - ); - } - // SAFETY: file owns a DELETE-capable handle and information spans total_bytes bytes. - if unsafe { - SetFileInformationByHandle( - file.as_raw_handle().cast(), - FileRenameInfo, - information.cast(), - total_bytes as u32, - ) - } == 0 - { - return Err(format!( - "按句柄安装 {label} 失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) -} - -#[cfg(windows)] -fn remove_windows_tool_plan_file_at( - parent: &File, - file_name: &str, - label: &str, -) -> Result { - let Some(file) = try_open_windows_tool_plan_file_at(parent, file_name, label, true, true)? - else { - return Ok(false); - }; - set_windows_tool_plan_file_deleted(&file, label)?; - Ok(true) -} - -#[cfg(windows)] -fn create_windows_tool_plan_temp_file_at( - parent: &File, - run_key: &str, -) -> Result<(String, File), String> { - for _ in 0..32 { - let file_name = format!( - ".{run_key}.json.tmp.{}.{}", - std::process::id(), - next_tool_plan_temp_nonce() - ); - match nt_open_windows_tool_plan_relative( - parent, - &file_name, - false, - WindowsToolPlanOpenDisposition::CreateNew, - true, - true, - true, - ) { - Ok(file) => { - validate_windows_tool_plan_file_handle(&file, "tool-plan 成功响应交接临时文件")?; - return Ok((file_name, file)); - } - Err(error) - if error.kind() == std::io::ErrorKind::AlreadyExists - || matches!(error.raw_os_error(), Some(80) | Some(183)) => - { - continue; - } - Err(error) => { - return Err(format!( - "创建 Windows tool-plan 成功响应交接临时文件失败:{error}" - )); - } - } - } - Err("创建 Windows tool-plan 成功响应交接临时文件失败:名称冲突".to_string()) -} - -#[cfg(windows)] -fn write_ledger_at_windows( - root: &Path, - ledger: &AgentRuntimeToolPlanHandoffLedger, - bytes: &[u8], -) -> Result<(), String> { - let storage = open_windows_tool_plan_agent_storage(root, &ledger.agent_id, true)? - .ok_or_else(|| "创建 Windows tool-plan 成功响应交接存储目录失败".to_string())?; - let run_key = path_key(&ledger.run_id); - let primary_name = format!("{run_key}.json"); - let previous_name = format!(".{run_key}.json.previous"); - let (_temporary_name, mut temporary_file) = - create_windows_tool_plan_temp_file_at(&storage.agent_directory, &run_key)?; - if let Err(error) = temporary_file - .write_all(bytes) - .and_then(|_| temporary_file.sync_data()) - { - let _ = - set_windows_tool_plan_file_deleted(&temporary_file, "tool-plan 成功响应交接临时文件"); - return Err(format!( - "写入 Windows tool-plan 成功响应交接临时文件失败:{error}" - )); - } - remove_windows_tool_plan_file_at( - &storage.agent_directory, - &previous_name, - "tool-plan 成功响应交接恢复副本", - )?; - if let Err(error) = rename_windows_tool_plan_file_at( - &temporary_file, - &storage.agent_directory, - &primary_name, - true, - "tool-plan 成功响应交接账本", - ) { - let _ = - set_windows_tool_plan_file_deleted(&temporary_file, "tool-plan 成功响应交接临时文件"); - return Err(error); - } - temporary_file - .sync_all() - .map_err(|error| format!("同步 Windows tool-plan 成功响应交接账本失败:{error}"))?; - storage.verify()?; - Ok(()) -} - -#[cfg(windows)] -fn remove_at_windows(root: &Path, agent_id: &str, run_id: &str) -> Result<(), String> { - let Some(storage) = open_windows_tool_plan_agent_storage(root, agent_id, false)? else { - return Ok(()); - }; - let run_key = path_key(run_id); - remove_windows_tool_plan_file_at( - &storage.agent_directory, - &format!(".{run_key}.json.previous"), - "tool-plan 成功响应交接恢复副本", - )?; - remove_windows_tool_plan_file_at( - &storage.agent_directory, - &format!("{run_key}.json"), - "tool-plan 成功响应交接账本", - )?; - storage.verify() -} - -#[cfg(windows)] -fn list_at_windows(root: &Path) -> Result, String> { - let Some(root_storage) = open_windows_tool_plan_root_storage(root, false)? else { - return Ok(Vec::new()); - }; - let agent_names = read_windows_tool_plan_directory_names( - &root_storage.handoff_directory, - "Windows tool-plan Agent 根目录", - TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS, - )?; - let mut discovered = BTreeMap::<(String, String), DiscoveredToolPlanHandoffLedgers>::new(); - let mut discovered_file_count = 0usize; - for (agent_index, agent_key) in agent_names.into_iter().enumerate() { - if agent_index >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS { - return Err(format!( - "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS} 个 Agent 上限" - )); - } - if !is_handoff_path_key(&agent_key) { - return Err(format!( - "tool-plan 成功响应交接 Agent 目录名不是规范 hash:{agent_key}" - )); - } - let agent_directory = open_windows_tool_plan_directory_at( - &root_storage.handoff_directory, - &agent_key, - "tool-plan 成功响应交接 Agent 目录", - false, - )? - .ok_or_else(|| "tool-plan 成功响应交接 Agent 目录在扫描期间消失".to_string())?; - let run_names = read_windows_tool_plan_directory_names( - &agent_directory, - "Windows tool-plan run 目录", - TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES, - )?; - for (run_index, file_name) in run_names.into_iter().enumerate() { - if run_index >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES - || discovered_file_count >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES - { - return Err(format!( - "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES} 个文件上限" - )); - } - discovered_file_count += 1; - match classify_handoff_file_name(&file_name) { - Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) => { - let ledger = try_read_windows_discovered_ledger_file( - root, - &agent_directory, - &file_name, - &agent_key, - run_key, - )? - .ok_or_else(|| "tool-plan 成功响应交接 primary 在扫描期间消失".to_string())?; - let files = - discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; - if files.primary.replace(ledger).is_some() { - return Err("tool-plan 成功响应交接 primary 项冲突".to_string()); - } - } - Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) => { - let ledger = try_read_windows_discovered_ledger_file( - root, - &agent_directory, - &file_name, - &agent_key, - run_key, - )? - .ok_or_else(|| ".previous 在 Windows tool-plan 扫描期间消失".to_string())?; - let files = - discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; - if files.previous.replace(ledger).is_some() { - return Err("tool-plan 成功响应交接 .previous 项冲突".to_string()); - } - } - Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => { - if !is_handoff_path_key(run_key) { - return Err(format!( - "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" - )); - } - remove_windows_tool_plan_file_at( - &agent_directory, - &file_name, - "tool-plan 成功响应交接原子临时文件", - )?; - } - None => { - return Err(format!( - "tool-plan 成功响应交接目录包含未知文件:{file_name}" - )); - } - } - } - verify_windows_tool_plan_entry( - &root_storage.handoff_directory, - &agent_key, - &agent_directory, - true, - "tool-plan 成功响应交接 Agent 目录", - )?; - } - root_storage.verify()?; - let mut ledgers = Vec::with_capacity(discovered.len()); - for ((agent_key, run_key), files) in discovered { - if let Some(ledger) = - select_primary_and_previous(&agent_key, &run_key, files.primary, files.previous)? - { - ledgers.push(ledger); - } - } - ledgers.sort_by(|left, right| { - left.agent_id - .cmp(&right.agent_id) - .then_with(|| left.run_id.cmp(&right.run_id)) - }); - Ok(ledgers) -} - -fn ledger_is_prefix( - previous: &AgentRuntimeToolPlanHandoffLedger, - primary: &AgentRuntimeToolPlanHandoffLedger, -) -> bool { - previous.schema_version == primary.schema_version - && previous.agent_id == primary.agent_id - && previous.run_id == primary.run_id - && previous.entries.len() <= primary.entries.len() - && previous.entries == primary.entries[..previous.entries.len()] -} - -fn select_primary_and_previous( - agent_key: &str, - run_key: &str, - primary: Option, - previous: Option, -) -> Result, String> { - match (primary, previous) { - (Some(primary), Some(previous)) => { - if !ledger_is_prefix(&previous, &primary) { - return Err(format!( - "tool-plan 成功响应交接 primary/.previous 内容冲突:{agent_key}/{run_key}" - )); - } - Ok(Some(primary)) - } - (Some(primary), None) => Ok(Some(primary)), - (None, Some(previous)) => Ok(Some(previous)), - (None, None) => Ok(None), - } -} - -fn is_handoff_path_key(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) -} - -fn response_fingerprint(response: &AgentRuntimeToolPlanHandoffResponse) -> Result { - let bytes = serde_json::to_vec(response) - .map_err(|error| format!("序列化 tool-plan 成功响应指纹失败:{error}"))?; - Ok(format!("{:x}", Sha256::digest(bytes))) -} - -fn validate_path_identity(agent_id: &str, run_id: &str) -> Result<(), String> { - if agent_id.trim().is_empty() || run_id.trim().is_empty() { - return Err("tool-plan 成功响应交接路径的 Agent/run 身份不能为空".to_string()); - } - Ok(()) -} - -#[cfg(test)] -fn tool_plan_handoff_relative_path(agent_id: &str, run_id: &str) -> String { - format!( - "{TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY}/{}/{}.json", - path_key(agent_id), - path_key(run_id) - ) -} - -#[cfg(any(test, not(unix)))] -#[cfg(test)] -fn tool_plan_handoff_path(root: &Path, agent_id: &str, run_id: &str) -> PathBuf { - root.join(tool_plan_handoff_relative_path(agent_id, run_id)) -} - -fn path_key(value: &str) -> String { - format!("{:x}", Sha256::digest(value.as_bytes())) -} - -fn next_tool_plan_temp_nonce() -> u128 { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - timestamp.saturating_add(u128::from( - TOOL_PLAN_HANDOFF_TEMP_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - )) -} - -#[cfg(test)] -mod tests { - use std::fs; - - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use tempfile::tempdir; - - use super::*; - use crate::agent::write_agent_runtime_json_sidecar_with_max_bytes; - - fn identity(slot: &str) -> AgentRuntimeProviderRetryIdentity { - identity_for(slot, "project-supervisor", "run-tool-plan-handoff") - } - - fn identity_for(slot: &str, agent_id: &str, run_id: &str) -> AgentRuntimeProviderRetryIdentity { - let (_, repair_attempt) = parse_tool_plan_base_request_slot(slot).expect("valid test slot"); - AgentRuntimeProviderRetryIdentity { - project_id: "project-tool-plan-handoff".to_string(), - agent_id: agent_id.to_string(), - task_id: "task-tool-plan-handoff".to_string(), - session_id: "session-tool-plan-handoff".to_string(), - run_id: run_id.to_string(), - source: "agent-background-task".to_string(), - goal_id: Some("goal-tool-plan-handoff".to_string()), - goal_revision: 4, - goal_snapshot_fingerprint: "a".repeat(64), - applied_steer_cursor: 2, - request_kind: "tool-plan".to_string(), - base_request_slot: slot.to_string(), - request_fingerprint: format!("{:x}", Sha256::digest(slot.as_bytes())), - provider_config_fingerprint: "b".repeat(64), - web_search_enabled: repair_attempt == 0, - allow_idle_context_compaction: false, - } - } - - fn response(text: &str, tool_calls: Vec) -> LlmRunResponse { - LlmRunResponse { - provider: LlmProvider::OpenAiCompatible, - model: "tool-plan-handoff-model".to_string(), - text: text.to_string(), - finish_reason: Some("tool_calls".to_string()), - response_id: Some("tool-plan-handoff-response".to_string()), - usage: Some(LlmTokenUsage { - prompt_tokens: 21, - completion_tokens: 13, - total_tokens: 34, - }), - tool_calls, - } - } - - fn call(id: &str, name: &str, arguments: &str) -> LlmToolCall { - LlmToolCall { - id: id.to_string(), - name: name.to_string(), - arguments: arguments.to_string(), - } - } - - fn provider_request_id(marker: &str) -> String { - format!("provider-request-{:x}", Sha256::digest(marker.as_bytes())) - } - - fn request_slot(identity: &AgentRuntimeProviderRetryIdentity, attempt: u32) -> String { - request_slot_for_attempt(identity, attempt) - } - - fn write( - root: &Path, - identity: &AgentRuntimeProviderRetryIdentity, - attempt: u32, - response: &LlmRunResponse, - ) -> AgentRuntimeToolPlanHandoffEntry { - write_at( - root, - identity, - &request_slot(identity, attempt), - attempt, - &provider_request_id(&format!("{}-{attempt}", identity.base_request_slot)), - response, - ) - .expect("write tool-plan handoff") - } - - #[test] - fn tool_plan_handoff_exact_tool_calls_round_trip_and_lookup() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let expected = response( - "执行精确工具计划。", - vec![ - call("call-1", "file.write", r#"{"path":"src/main.rs"}"#), - call("call-2", "project.verify", r#"{"unterminated":"#), - ], - ); - let entry = write(project.path(), &identity, 0, &expected); - - assert_eq!(entry.identity, identity); - assert_eq!(entry.loop_iteration, 0); - assert_eq!(entry.repair_attempt, 0); - assert_eq!(entry.to_llm_response(), expected); - assert_eq!( - lookup_at( - project.path(), - &identity.agent_id, - &identity.run_id, - &identity, - ) - .expect("lookup tool-plan handoff"), - AgentRuntimeToolPlanHandoffLookup::Exact(entry.clone()) - ); - let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) - .expect("read tool-plan handoff") - .expect("tool-plan handoff ledger"); - assert_eq!(ledger.entries, vec![entry]); - - #[cfg(unix)] - assert_eq!( - fs::metadata(tool_plan_handoff_path( - project.path(), - &identity.agent_id, - &identity.run_id, - )) - .expect("tool-plan handoff metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - } - - #[test] - fn tool_plan_handoff_strips_thinking_before_persisting() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let private_thinking = "TOOL_PLAN_PRIVATE_THINKING_MUST_NOT_PERSIST"; - let entry = write( - project.path(), - &identity, - 0, - &response( - &format!("{private_thinking}\n公开计划"), - vec![call("call-1", "update_agent_plan", "{}")], - ), - ); - - assert_eq!(entry.to_llm_response().text, "公开计划"); - assert_eq!( - entry - .thinking_normalization_metadata() - .map(|(count, chars, fingerprint)| (count, chars, fingerprint.len())), - Some(( - 1, - format!("{private_thinking}\n公开计划") - .chars() - .count(), - 64, - )) - ); - let persisted = fs::read_to_string(tool_plan_handoff_path( - project.path(), - &identity.agent_id, - &identity.run_id, - )) - .expect("read persisted tool-plan handoff"); - assert!(!persisted.contains(private_thinking)); - assert!(!persisted.to_ascii_lowercase().contains("")); - } - - #[test] - fn tool_plan_handoff_invalid_thinking_wrappers_replay_bodyless_protocol_markers() { - let cases = [ - ( - "UNTERMINATED_PRIVATE_THINKING", - true, - false, - INVALID_THINKING_OPEN_MARKER, - ), - ( - "ORPHAN_PRIVATE_THINKING", - false, - true, - INVALID_THINKING_CLOSE_MARKER, - ), - ( - "MISMATCHED_PRIVATE_THINKING", - false, - false, - INVALID_THINKING_CLOSE_MARKER, - ), - ]; - - for (index, (source, wrapper_valid, wrapper_balanced, marker)) in - cases.into_iter().enumerate() - { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let entry = write( - project.path(), - &identity, - 0, - &response( - source, - vec![call("call-invalid-think", "project.verify", "{}")], - ), - ); - - assert_eq!(entry.response.text, "", "case {index}"); - assert_eq!( - entry.response.thinking_wrapper_valid, wrapper_valid, - "case {index}" - ); - assert_eq!( - entry.response.thinking_wrapper_balanced, wrapper_balanced, - "case {index}" - ); - assert!(entry.response.thinking_source_text_chars > 0); - assert_eq!( - entry - .response - .thinking_source_text_sha256 - .as_deref() - .map(str::len), - Some(64) - ); - assert!(entry.thinking_normalization_metadata().is_none()); - - let replayed = entry.to_llm_response(); - assert_eq!(replayed.text, marker, "case {index}"); - assert!( - crate::agent::parse_game_creator_agent_tool_plan_llm_response(&replayed).is_err() - ); - let persisted = fs::read_to_string(tool_plan_handoff_path( - project.path(), - &identity.agent_id, - &identity.run_id, - )) - .expect("read persisted invalid thinking handoff"); - assert!(!persisted.contains(source)); - assert!(!persisted.contains("PRIVATE_THINKING")); - assert!(persisted.contains("\"thinkingWrapperValid\"")); - assert!(persisted.contains("\"thinkingWrapperBalanced\"")); - } - } - - #[test] - fn tool_plan_handoff_strips_nested_balanced_thinking_without_leaking_body() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let entry = write( - project.path(), - &identity, - 0, - &response( - "OUTER_PRIVATEINNER_PRIVATEvisible", - vec![call("call-balanced-think", "project.verify", "{}")], - ), - ); - - assert_eq!(entry.to_llm_response().text, "visible"); - assert!(entry.response.thinking_wrapper_valid); - assert!(entry.response.thinking_wrapper_balanced); - assert_eq!(entry.response.thinking_normalization_count, 1); - let persisted = fs::read_to_string(tool_plan_handoff_path( - project.path(), - &identity.agent_id, - &identity.run_id, - )) - .expect("read nested thinking handoff"); - assert!(!persisted.contains("OUTER_PRIVATE")); - assert!(!persisted.contains("INNER_PRIVATE")); - } - - #[test] - fn tool_plan_handoff_appends_base_repair_and_next_loop_monotonically() { - let project = tempdir().expect("tool-plan handoff project"); - let base = identity("loop-0-repair-0"); - let repair = identity("loop-0-repair-1"); - let next_loop = identity("loop-1-repair-0"); - write(project.path(), &base, 0, &response("base", Vec::new())); - write( - project.path(), - &repair, - 2, - &response("repair", vec![call("call-r", "respond_to_user", "{}")]), - ); - write( - project.path(), - &next_loop, - 0, - &response("next loop", Vec::new()), - ); - - let ledger = read_for_run_at(project.path(), &base.agent_id, &base.run_id) - .expect("read tool-plan handoff") - .expect("tool-plan handoff ledger"); - assert_eq!( - ledger - .entries - .iter() - .map(|entry| (entry.loop_iteration, entry.repair_attempt, entry.attempt)) - .collect::>(), - vec![(0, 0, 0), (0, 1, 2), (1, 0, 0)] - ); - assert!(is_later_repair_identity(&base, &repair)); - assert!(!is_later_repair_identity(&repair, &base)); - assert!(!is_later_repair_identity(&base, &next_loop)); - } - - #[test] - fn tool_plan_handoff_same_slot_is_idempotent() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-3-repair-0"); - let expected = response( - "idempotent", - vec![call("call-idempotent", "project.verify", "{}")], - ); - let first = write(project.path(), &identity, 1, &expected); - let second = write(project.path(), &identity, 1, &expected); - assert_eq!(first, second); - assert_eq!( - read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) - .expect("read tool-plan handoff") - .expect("tool-plan handoff ledger") - .entries - .len(), - 1 - ); - } - - #[test] - fn tool_plan_handoff_reports_identity_and_payload_conflicts() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let expected = response("original", Vec::new()); - let entry = write(project.path(), &identity, 0, &expected); - - let mut conflicting_identity = identity.clone(); - conflicting_identity.request_fingerprint = "c".repeat(64); - assert_eq!( - lookup_at( - project.path(), - &identity.agent_id, - &identity.run_id, - &conflicting_identity, - ) - .expect("lookup conflicting tool-plan handoff"), - AgentRuntimeToolPlanHandoffLookup::IdentityConflict(entry) - ); - let error = write_at( - project.path(), - &conflicting_identity, - &conflicting_identity.base_request_slot, - 0, - &provider_request_id("conflicting-identity"), - &expected, - ) - .expect_err("conflicting identity must fail"); - assert!(error.contains("同一 slot 内容冲突")); - - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id("conflicting-request-id"), - &expected, - ) - .expect_err("conflicting requestId must fail"); - assert!(error.contains("同一 slot 内容冲突")); - let error = write_at( - project.path(), - &identity, - &request_slot(&identity, 1), - 1, - &provider_request_id("conflicting-attempt"), - &expected, - ) - .expect_err("conflicting attempt must fail"); - assert!(error.contains("同一 slot 内容冲突")); - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id("loop-0-repair-0-0"), - &response("different response", Vec::new()), - ) - .expect_err("conflicting response must fail"); - assert!(error.contains("同一 slot 内容冲突")); - } - - #[test] - fn tool_plan_handoff_rejects_out_of_order_entries() { - let project = tempdir().expect("tool-plan handoff project"); - let repair_without_base = identity("loop-0-repair-1"); - let error = write_at( - project.path(), - &repair_without_base, - &repair_without_base.base_request_slot, - 0, - &provider_request_id("repair-without-base"), - &response("repair", Vec::new()), - ) - .expect_err("repair without base must fail"); - assert!(error.contains("repair-0")); - - let base = identity("loop-0-repair-0"); - write(project.path(), &base, 0, &response("base", Vec::new())); - let repair_gap = identity("loop-0-repair-2"); - let error = write_at( - project.path(), - &repair_gap, - &repair_gap.base_request_slot, - 0, - &provider_request_id("repair-gap"), - &response("repair gap", Vec::new()), - ) - .expect_err("repair gap must fail"); - assert!(error.contains("连续追加")); - - let mut drifted_repair = identity("loop-0-repair-1"); - drifted_repair.goal_revision += 1; - let error = write_at( - project.path(), - &drifted_repair, - &drifted_repair.base_request_slot, - 0, - &provider_request_id("repair-identity-drift"), - &response("repair identity drift", Vec::new()), - ) - .expect_err("same loop repair identity drift must fail"); - assert!(error.contains("repair 链身份冲突")); - - let next_loop_repair = identity("loop-1-repair-1"); - let error = write_at( - project.path(), - &next_loop_repair, - &next_loop_repair.base_request_slot, - 0, - &provider_request_id("next-loop-repair"), - &response("next loop repair", Vec::new()), - ) - .expect_err("new loop repair must start at zero"); - assert!(error.contains("repair-0")); - - let future = identity("loop-2-repair-0"); - write( - project.path(), - &future, - 0, - &response("future loop", Vec::new()), - ); - let missing_middle = identity("loop-1-repair-0"); - let error = lookup_at( - project.path(), - &missing_middle.agent_id, - &missing_middle.run_id, - &missing_middle, - ) - .expect_err("future entry must prevent replaying an older missing request"); - assert!(error.contains("未来 entry")); - - let ledger = read_for_run_at(project.path(), &base.agent_id, &base.run_id) - .expect("read ordered handoff") - .expect("ordered handoff ledger"); - assert_eq!(ledger.entries.len(), 2); - } - - #[test] - fn tool_plan_handoff_rejects_dangerous_content_and_invalid_calls_without_writing() { - let cases = [ - response("load .env.local", Vec::new()), - response( - "safe text", - vec![call("call-path", "file.write", r#"{"path":"/etc/passwd"}"#)], - ), - response( - "safe text", - vec![call( - "call-escaped-path", - "file.write", - r#"{"path":"\u002fetc\u002fpasswd"}"#, - )], - ), - response( - "safe text", - vec![call("call-secret", "file.write", r#"{"api_key":"secret"}"#)], - ), - response("safe text", vec![call("", "file.write", "{}")]), - response( - "safe text", - vec![call("call-control", "file.\nwrite", "{}")], - ), - ]; - - for (index, response) in cases.into_iter().enumerate() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id(&format!("dangerous-{index}")), - &response, - ) - .expect_err("dangerous tool-plan handoff must fail"); - assert!( - error.contains("敏感") || error.contains("绝对路径") || error.contains("无效"), - "unexpected error: {error}" - ); - assert!( - !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,) - .exists() - ); - } - - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let project_path_argument = format!(r#"{{"path":"{}"}}"#, project.path().display()); - write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id("project-path"), - &response( - "safe text", - vec![call( - "call-project-path", - "file.write", - &project_path_argument, - )], - ), - ) - .expect_err("project path must fail"); - assert!( - !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,).exists() - ); - } - - #[test] - fn tool_plan_handoff_rejects_sensitive_keys_in_valid_and_malformed_json() { - let sensitive_keys = [ - "x-api-key", - "apiKey", - "token", - "access_token", - "refresh_token", - "password", - "client_secret", - "private_key", - "credential", - "openai_api_key", - "github_token", - "db_password", - "webhook_secret", - "secrets", - "clientSecrets", - "secretKey", - "apiKeys", - "privateKeys", - "credentials", - ]; - for (index, key) in sensitive_keys.into_iter().enumerate() { - for (shape, arguments) in [ - ("valid", format!(r#"{{"{key}":"placeholder"}}"#)), - ("malformed", format!(r#"{{"{key}":"placeholder""#)), - ( - "commented", - format!(r#"{{"{key}"/*untrusted*/:"placeholder"}}"#), - ), - ] { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id(&format!("sensitive-key-{index}-{shape}")), - &response( - "safe text", - vec![call("call-sensitive-key", "project.verify", &arguments)], - ), - ) - .expect_err("sensitive JSON keys must fail closed"); - assert!(error.contains("敏感 JSON key"), "unexpected error: {error}"); - assert!(!tool_plan_handoff_path( - project.path(), - &identity.agent_id, - &identity.run_id, - ) - .exists()); - } - } - - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let unicode_key = r#"{"to\u006ben":"placeholder""#; - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id("unicode-sensitive-key"), - &response( - "safe text", - vec![call("call-unicode-key", "project.verify", unicode_key)], - ), - ) - .expect_err("escaped sensitive JSON key must fail closed"); - assert!(error.contains("敏感 JSON key")); - } - - #[test] - fn tool_plan_handoff_rejects_nested_and_malformed_absolute_path_inputs() { - for (index, arguments) in [ - r#"{"content":{"path":"/home/user/private"}}"#, - r#"{"path":"/home/user/private""#, - r#"{"path":"/home/user/private"#, - r#"{paths:["/home/user/private"]"#, - r#"{"path":"C:\Users\alice"}"#, - r#"{"path":"C:\Users\alice"#, - r#"{"bad\uZZZZ":"x","path":"/etc/passwd"}"#, - r#"{"path"/*untrusted*/:"/etc/passwd"}"#, - r#"{"path":/*untrusted*/"C:\Users\alice"}"#, - ] - .into_iter() - .enumerate() - { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id(&format!("absolute-path-shape-{index}")), - &response( - "safe text", - vec![call("call-absolute-path", "file.write", arguments)], - ), - ) - .expect_err("nested and malformed absolute paths must fail closed"); - assert!(error.contains("绝对路径"), "unexpected error: {error}"); - } - - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id("malformed-text-absolute-path"), - &response(r#"{"path":"/etc/passwd"#, Vec::new()), - ) - .expect_err("malformed text JSON absolute path must fail closed"); - assert!(error.contains("绝对路径"), "unexpected error: {error}"); - } - - #[test] - fn tool_plan_handoff_allows_ordinary_source_in_content_html_and_patch_fields() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let arguments = serde_json::json!({ - "path": "src/security-form.ts", - "content": "const token = props.token; const apiKey = options.apiKey; const samplePath = '/home/example';", - "html": "", - "patch": "const defaults = { client_secret: label, private_key: fieldName };" - }) - .to_string(); - let expected = response( - "safe source plan", - vec![call("call-source-fields", "file.write", &arguments)], - ); - - let entry = write(project.path(), &identity, 0, &expected); - assert_eq!(entry.to_llm_response(), expected); - } - - #[test] - fn tool_plan_handoff_rejects_secrets_and_absolute_paths_in_short_metadata() { - enum MetadataField { - Model, - FinishReason, - ResponseId, - ToolCallId, - ToolCallName, - } - - for (index, (field, value)) in [ - (MetadataField::Model, "sk-0123456789abcdef"), - (MetadataField::Model, "/tmp/private-model"), - (MetadataField::FinishReason, "sk-0123456789abcdef"), - (MetadataField::FinishReason, "/tmp/private-finish"), - (MetadataField::ResponseId, "sk-0123456789abcdef"), - (MetadataField::ResponseId, "/tmp/private-response"), - (MetadataField::ToolCallId, "sk-0123456789abcdef"), - (MetadataField::ToolCallId, "/tmp/private-call-id"), - (MetadataField::ToolCallName, "credential"), - (MetadataField::ToolCallName, "/tmp/private-call-name"), - ] - .into_iter() - .enumerate() - { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let mut candidate = - response("safe text", vec![call("call-safe", "project.verify", "{}")]); - match field { - MetadataField::Model => candidate.model = value.to_string(), - MetadataField::FinishReason => { - candidate.finish_reason = Some(value.to_string()); - } - MetadataField::ResponseId => candidate.response_id = Some(value.to_string()), - MetadataField::ToolCallId => candidate.tool_calls[0].id = value.to_string(), - MetadataField::ToolCallName => candidate.tool_calls[0].name = value.to_string(), - } - let error = write_at( - project.path(), - &identity, - &identity.base_request_slot, - 0, - &provider_request_id(&format!("unsafe-short-metadata-{index}")), - &candidate, - ) - .expect_err("unsafe short metadata must fail closed"); - assert!( - error.contains("敏感") || error.contains("绝对路径"), - "unexpected error: {error}" - ); - assert!( - !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,) - .exists() - ); - } - } - - #[test] - fn tool_plan_handoff_allows_source_content_without_treating_html_tags_as_paths() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let plan = serde_json::json!({ - "thinkingSummary": "写入可玩页面", - "planUpdate": null, - "plan": ["写入 game/index.html"], - "actions": [{ - "tool": "file.write", - "reason": "写入 HTML", - "input": { - "path": "game/index.html", - "content": "" - } - }], - "response": "" - }) - .to_string(); - let entry = write(project.path(), &identity, 0, &response(&plan, Vec::new())); - assert_eq!(entry.to_llm_response().text, plan); - } - - #[test] - fn tool_plan_handoff_rejects_call_argument_entry_and_total_limits() { - let project = tempdir().expect("tool-plan handoff project"); - let base_identity = identity("loop-0-repair-0"); - let too_many_calls = (0..=TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS) - .map(|index| call(&format!("call-{index}"), "project.verify", "{}")) - .collect(); - assert!(write_at( - project.path(), - &base_identity, - &base_identity.base_request_slot, - 0, - &provider_request_id("too-many-calls"), - &response("calls", too_many_calls), - ) - .expect_err("tool call count must be bounded") - .contains("tool calls")); - - let too_large_argument = "x".repeat(TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES + 1); - assert!(write_at( - project.path(), - &base_identity, - &base_identity.base_request_slot, - 0, - &provider_request_id("too-large-argument"), - &response( - "argument", - vec![call("call-large", "project.verify", &too_large_argument)], - ), - ) - .expect_err("tool arguments must be bounded") - .contains("arguments")); - - let total_too_large = (0..17) - .map(|index| { - call( - &format!("call-total-{index}"), - "project.verify", - &"x".repeat(TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES), - ) - }) - .collect(); - assert!(write_at( - project.path(), - &base_identity, - &base_identity.base_request_slot, - 0, - &provider_request_id("total-too-large"), - &response("total", total_too_large), - ) - .expect_err("tool-plan ledger bytes must be bounded") - .contains("字节上限")); - - let entries = (0..TOOL_PLAN_HANDOFF_MAX_ENTRIES) - .map(|index| { - let identity = identity(&format!("loop-{index}-repair-0")); - let response = response(&format!("entry-{index}"), Vec::new()); - let persisted = response_for_persistence(project.path(), &response) - .expect("build persisted response"); - AgentRuntimeToolPlanHandoffEntry { - identity, - provider_request_id: provider_request_id(&format!("entry-{index}")), - request_slot: format!("loop-{index}-repair-0"), - attempt: 0, - loop_iteration: index as u64, - repair_attempt: 0, - response_fingerprint: response_fingerprint(&persisted) - .expect("fingerprint response"), - response: persisted, - created_at_ms: index as u64 + 1, - } - }) - .collect::>(); - let ledger = AgentRuntimeToolPlanHandoffLedger { - schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), - agent_id: base_identity.agent_id.clone(), - run_id: base_identity.run_id.clone(), - entries, - }; - validate_ledger(project.path(), &ledger).expect("validate full handoff ledger"); - write_agent_runtime_json_sidecar_with_max_bytes( - project.path(), - &tool_plan_handoff_relative_path(&base_identity.agent_id, &base_identity.run_id), - TOOL_PLAN_HANDOFF_LABEL, - &ledger, - TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, - ) - .expect("write full handoff ledger"); - let next = identity(&format!("loop-{TOOL_PLAN_HANDOFF_MAX_ENTRIES}-repair-0")); - assert!(ensure_capacity_for_request_at( - project.path(), - &next.agent_id, - &next.run_id, - &next, - ) - .expect_err("entry capacity must fail before a Provider request") - .contains("请求前账本容量")); - assert!(write_at( - project.path(), - &next, - &next.base_request_slot, - 0, - &provider_request_id("entry-overflow"), - &response("overflow", Vec::new()), - ) - .expect_err("entry count must be bounded") - .contains("条上限")); - } - - #[test] - fn tool_plan_handoff_rejects_insufficient_byte_reserve_before_provider_request() { - let project = tempdir().expect("tool-plan handoff project"); - let base_identity = identity("loop-0-repair-0"); - let mut entries = Vec::new(); - while entries.len() < TOOL_PLAN_HANDOFF_MAX_ENTRIES { - let index = entries.len(); - let entry_identity = identity(&format!("loop-{index}-repair-0")); - let persisted = response_for_persistence( - project.path(), - &response(&"x".repeat(220_000), Vec::new()), - ) - .expect("build byte reserve response"); - entries.push(AgentRuntimeToolPlanHandoffEntry { - identity: entry_identity, - provider_request_id: provider_request_id(&format!("byte-reserve-{index}")), - request_slot: format!("loop-{index}-repair-0"), - attempt: 0, - loop_iteration: index as u64, - repair_attempt: 0, - response_fingerprint: response_fingerprint(&persisted) - .expect("fingerprint byte reserve response"), - response: persisted, - created_at_ms: index as u64 + 1, - }); - let candidate = AgentRuntimeToolPlanHandoffLedger { - schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), - agent_id: base_identity.agent_id.clone(), - run_id: base_identity.run_id.clone(), - entries: entries.clone(), - }; - let bytes = serde_json::to_vec_pretty(&candidate) - .expect("serialize byte reserve candidate") - .len() - + 1; - if bytes.saturating_add(TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES) - > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES - && bytes <= TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES - { - validate_ledger(project.path(), &candidate).expect("validate byte reserve ledger"); - write_agent_runtime_json_sidecar_with_max_bytes( - project.path(), - &tool_plan_handoff_relative_path( - &base_identity.agent_id, - &base_identity.run_id, - ), - TOOL_PLAN_HANDOFF_LABEL, - &candidate, - TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, - ) - .expect("write byte reserve ledger"); - let next = identity(&format!("loop-{}-repair-0", entries.len())); - let error = ensure_capacity_for_request_at( - project.path(), - &next.agent_id, - &next.run_id, - &next, - ) - .expect_err("byte reserve must fail before a Provider request"); - assert!(error.contains("请求前账本剩余空间")); - return; - } - if bytes > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { - break; - } - } - panic!("test fixture did not reach the request reserve boundary"); - } - - #[test] - fn tool_plan_handoff_strict_read_rejects_unknown_and_corrupt_fields() { - fn mutate_and_read_error(mutate: impl FnOnce(&mut serde_json::Value)) -> String { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write( - project.path(), - &identity, - 0, - &response("strict", Vec::new()), - ); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let mut value: serde_json::Value = - serde_json::from_slice(&fs::read(&path).expect("read tool-plan handoff bytes")) - .expect("parse tool-plan handoff JSON"); - mutate(&mut value); - fs::write( - &path, - serde_json::to_vec_pretty(&value).expect("serialize mutated handoff"), - ) - .expect("write mutated handoff"); - read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) - .expect_err("mutated handoff must fail") - } - - let error = mutate_and_read_error(|value| { - value - .as_object_mut() - .expect("ledger object") - .insert("unknownField".to_string(), serde_json::json!(true)); - }); - assert!(error.contains("unknown field")); - - let error = mutate_and_read_error(|value| { - value["entries"][0]["responseFingerprint"] = serde_json::json!("0".repeat(64)); - }); - assert!(error.contains("responseFingerprint")); - - let error = mutate_and_read_error(|value| { - value["entries"][0]["requestSlot"] = serde_json::json!("loop-0-repair-0-transient-1"); - }); - assert!(error.contains("requestSlot/attempt")); - - let error = mutate_and_read_error(|value| { - value["entries"][0]["providerRequestId"] = serde_json::json!("invalid-request-id"); - }); - assert!(error.contains("providerRequestId")); - - let error = mutate_and_read_error(|value| { - value["agentId"] = serde_json::json!("other-agent"); - }); - assert!(error.contains("Agent/run")); - } - - #[test] - fn tool_plan_handoff_previous_recovers_and_remove_deletes_both_copies() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - let entry = write( - project.path(), - &identity, - 0, - &response("recover", Vec::new()), - ); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let backup_path = agent_runtime_json_sidecar_backup_path(&path); - fs::rename(&path, &backup_path).expect("move handoff to previous"); - assert_eq!( - read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) - .expect("recover previous handoff") - .expect("recovered handoff") - .entries, - vec![entry] - ); - - fs::copy(&backup_path, &path).expect("restore primary while retaining previous"); - remove_at(project.path(), &identity.agent_id, &identity.run_id) - .expect("remove both handoff copies"); - assert!(!path.exists()); - assert!(!backup_path.exists()); - remove_at(project.path(), &identity.agent_id, &identity.run_id) - .expect("repeat handoff removal"); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_read_uses_open_agent_directory_after_path_replacement() { - use std::os::unix::fs::symlink; - - let project = tempdir().expect("tool-plan handoff project"); - let external = tempdir().expect("external handoff directory"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let agent_directory = path - .parent() - .expect("handoff agent directory") - .to_path_buf(); - let handoff_root = agent_directory - .parent() - .expect("handoff root directory") - .to_path_buf(); - let displaced = handoff_root.join("displaced-read-agent-directory"); - let external_primary = external - .path() - .join(path.file_name().expect("primary file name")); - fs::write(&external_primary, b"external primary must remain") - .expect("write external primary"); - fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) - .expect("set external primary permissions"); - - let error = read_for_run_at_unix_with_agent_open_hook( - project.path(), - &identity.agent_id, - &identity.run_id, - |_| { - fs::rename(&agent_directory, &displaced).expect("displace opened read directory"); - symlink(external.path(), &agent_directory) - .expect("replace read directory with symlink"); - }, - ) - .expect_err("replaced read directory must fail closed after fixed-handle read"); - assert!(error.contains("发生替换"), "unexpected error: {error}"); - assert_eq!( - fs::read(&external_primary).expect("read external primary"), - b"external primary must remain" - ); - - fs::remove_file(&agent_directory).expect("remove replacement symlink"); - fs::rename(&displaced, &agent_directory).expect("restore read directory"); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_write_uses_open_agent_directory_after_path_replacement() { - use std::os::unix::fs::symlink; - - let project = tempdir().expect("tool-plan handoff project"); - let external = tempdir().expect("external handoff directory"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) - .expect("read handoff ledger") - .expect("handoff ledger"); - let bytes = serialize_ledger_for_storage(&ledger).expect("serialize handoff ledger"); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let agent_directory = path - .parent() - .expect("handoff agent directory") - .to_path_buf(); - let handoff_root = agent_directory - .parent() - .expect("handoff root directory") - .to_path_buf(); - let displaced = handoff_root.join("displaced-write-agent-directory"); - let external_primary = external - .path() - .join(path.file_name().expect("primary file name")); - fs::write(&external_primary, b"external primary must remain") - .expect("write external primary"); - fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) - .expect("set external primary permissions"); - - let error = - write_ledger_at_unix_with_agent_open_hook(project.path(), &ledger, &bytes, |_| { - fs::rename(&agent_directory, &displaced).expect("displace opened write directory"); - symlink(external.path(), &agent_directory) - .expect("replace write directory with symlink"); - }) - .expect_err("replaced write directory must fail closed after fixed-handle write"); - assert!(error.contains("发生替换"), "unexpected error: {error}"); - assert_eq!( - fs::read(&external_primary).expect("read external primary"), - b"external primary must remain" - ); - - fs::remove_file(&agent_directory).expect("remove replacement symlink"); - fs::rename(&displaced, &agent_directory).expect("restore write directory"); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_write_rolls_back_when_temp_name_is_rebound_before_install() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let original_primary = fs::read(&path).expect("read original primary"); - let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) - .expect("read handoff ledger") - .expect("handoff ledger"); - let bytes = serialize_ledger_for_storage(&ledger).expect("serialize handoff ledger"); - let agent_directory = path - .parent() - .expect("handoff agent directory") - .to_path_buf(); - let displaced_temp = agent_directory.join("displaced-writer-temp"); - - let error = write_ledger_at_unix_with_hooks( - project.path(), - &ledger, - &bytes, - |_| {}, - |temporary_name| { - let temporary_path = agent_directory.join(temporary_name); - fs::rename(&temporary_path, &displaced_temp).expect("displace locked writer temp"); - fs::write(&temporary_path, b"replacement temp must not become primary") - .expect("write replacement temp"); - fs::set_permissions(&temporary_path, fs::Permissions::from_mode(0o600)) - .expect("set replacement temp permissions"); - }, - ) - .expect_err("rebound temp name must fail and restore the original primary"); - assert!(error.contains("身份冲突"), "unexpected error: {error}"); - assert_eq!( - fs::read(&path).expect("read restored primary"), - original_primary - ); - assert!(displaced_temp.exists()); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_remove_rejects_file_name_rebinding_before_unlink() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .expect("primary file name") - .to_string(); - let displaced = path.with_file_name("displaced-delete-ledger"); - let storage = open_unix_tool_plan_agent_storage(project.path(), &identity.agent_id, false) - .expect("open handoff storage") - .expect("handoff storage"); - lock_unix_tool_plan_directory( - &storage.agent_directory, - "tool-plan 成功响应交接 Agent 目录", - ) - .expect("lock handoff agent directory"); - - let error = remove_unix_tool_plan_file_at_with_hook( - &storage.agent_directory, - &file_name, - "tool-plan 成功响应交接账本", - |_| { - fs::rename(&path, &displaced).expect("displace opened primary"); - fs::write(&path, b"replacement must remain").expect("write replacement primary"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) - .expect("set replacement permissions"); - }, - ) - .expect_err("rebound delete name must fail before unlink"); - assert!(error.contains("名称换绑"), "unexpected error: {error}"); - assert_eq!( - fs::read(&path).expect("read replacement primary"), - b"replacement must remain" - ); - assert!(displaced.exists()); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_remove_uses_open_agent_directory_after_path_replacement() { - use std::os::unix::fs::symlink; - - let project = tempdir().expect("tool-plan handoff project"); - let external = tempdir().expect("external handoff directory"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let backup_path = agent_runtime_json_sidecar_backup_path(&path); - fs::copy(&path, &backup_path).expect("copy handoff previous"); - fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) - .expect("set previous permissions"); - let agent_directory = path - .parent() - .expect("handoff agent directory") - .to_path_buf(); - let handoff_root = agent_directory - .parent() - .expect("handoff root directory") - .to_path_buf(); - let displaced = handoff_root.join("displaced-remove-agent-directory"); - let external_primary = external - .path() - .join(path.file_name().expect("primary file name")); - let external_previous = external - .path() - .join(backup_path.file_name().expect("previous file name")); - fs::write(&external_primary, b"external primary must remain") - .expect("write external primary"); - fs::write(&external_previous, b"external previous must remain") - .expect("write external previous"); - fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) - .expect("set external primary permissions"); - fs::set_permissions(&external_previous, fs::Permissions::from_mode(0o600)) - .expect("set external previous permissions"); - - let error = remove_at_unix_with_agent_open_hook( - project.path(), - &identity.agent_id, - &identity.run_id, - |_| { - fs::rename(&agent_directory, &displaced).expect("displace opened remove directory"); - symlink(external.path(), &agent_directory) - .expect("replace remove directory with symlink"); - }, - ) - .expect_err("replaced remove directory must fail closed after fixed-handle deletion"); - assert!(error.contains("发生替换"), "unexpected error: {error}"); - assert!(external_primary.exists()); - assert!(external_previous.exists()); - assert!(!displaced - .join(path.file_name().expect("primary name")) - .exists()); - assert!(!displaced - .join(backup_path.file_name().expect("previous name")) - .exists()); - - fs::remove_file(&agent_directory).expect("remove replacement symlink"); - fs::rename(&displaced, &agent_directory).expect("restore remove directory"); - } - - #[test] - fn tool_plan_handoff_list_discovers_sorted_ledgers_validates_previous_and_cleans_safe_temp() { - let project = tempdir().expect("tool-plan handoff project"); - let identities = [ - identity_for("loop-0-repair-0", "quality-review", "run-2"), - identity_for("loop-0-repair-0", "design-director", "run-2"), - identity_for("loop-0-repair-0", "design-director", "run-1"), - ]; - for identity in &identities { - write(project.path(), identity, 0, &response("base", Vec::new())); - } - - let base = &identities[1]; - let path = tool_plan_handoff_path(project.path(), &base.agent_id, &base.run_id); - let base_bytes = fs::read(&path).expect("read base handoff bytes"); - let repair = identity_for("loop-0-repair-1", &base.agent_id, &base.run_id); - write( - project.path(), - &repair, - 0, - &response("repair", vec![call("call-repair", "project.verify", "{}")]), - ); - let backup_path = agent_runtime_json_sidecar_backup_path(&path); - fs::write(&backup_path, base_bytes).expect("restore valid previous prefix"); - #[cfg(unix)] - fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) - .expect("set previous permissions"); - - let temp_path = path.with_file_name(format!( - ".{}.tmp.{}.5678", - path.file_name() - .and_then(|value| value.to_str()) - .expect("handoff file name"), - i32::MAX, - )); - fs::write(&temp_path, b"partial atomic write").expect("write stale temp"); - #[cfg(unix)] - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) - .expect("set stale temp permissions"); - - let listed = list_at(project.path()).expect("list tool-plan handoffs"); - assert!(!temp_path.exists()); - assert_eq!( - listed - .iter() - .map(|ledger| (ledger.agent_id(), ledger.run_id(), ledger.entries.len())) - .collect::>(), - vec![ - ("design-director", "run-1", 1), - ("design-director", "run-2", 2), - ("quality-review", "run-2", 1), - ] - ); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_list_preserves_active_atomic_temp_file() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let temp_path = path.with_file_name(format!( - ".{}.tmp.{}.{}", - path.file_name() - .and_then(|value| value.to_str()) - .expect("handoff file name"), - std::process::id(), - provider_retry::now_ms(), - )); - let mut temp_file = std::fs::OpenOptions::new() - .create_new(true) - .read(true) - .write(true) - .open(&temp_path) - .expect("create active temp"); - temp_file - .write_all(b"active atomic write") - .expect("write active temp"); - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) - .expect("set active temp permissions"); - // SAFETY: the test owns temp_file and intentionally models the writer lock. - assert_eq!( - unsafe { libc::flock(temp_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, - 0 - ); - - let error = list_at(project.path()).expect_err("active temp must keep recovery busy"); - assert!(error.contains("活跃写入句柄"), "unexpected error: {error}"); - assert!(temp_path.exists()); - - drop(temp_file); - list_at(project.path()).expect("clean unlocked temp after writer closes"); - assert!(!temp_path.exists()); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_list_cleans_unlocked_atomic_temp_with_reused_pid() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let temp_path = path.with_file_name(format!( - ".{}.tmp.{}.{}", - path.file_name() - .and_then(|value| value.to_str()) - .expect("handoff file name"), - std::process::id(), - provider_retry::now_ms(), - )); - fs::write(&temp_path, b"stale temp from reused pid").expect("write stale temp"); - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) - .expect("set stale temp permissions"); - - list_at(project.path()).expect("same pid without writer lock is stale"); - assert!(!temp_path.exists()); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_temp_cleanup_rejects_name_rebinding_before_unlink() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let temp_path = path.with_file_name(format!( - ".{}.tmp.{}.{}", - path.file_name() - .and_then(|value| value.to_str()) - .expect("handoff file name"), - std::process::id(), - provider_retry::now_ms(), - )); - fs::write(&temp_path, b"stale temp").expect("write stale temp"); - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) - .expect("set stale temp permissions"); - let file_name = temp_path - .file_name() - .and_then(|value| value.to_str()) - .expect("temp file name") - .to_string(); - let displaced = temp_path.with_file_name("displaced-stale-temp"); - let storage = open_unix_tool_plan_agent_storage(project.path(), &identity.agent_id, false) - .expect("open handoff storage") - .expect("handoff storage"); - lock_unix_tool_plan_directory( - &storage.agent_directory, - "tool-plan 成功响应交接 Agent 目录", - ) - .expect("lock handoff agent directory"); - - let error = remove_stale_unix_handoff_temp_file_at_with_hook( - &storage.agent_directory, - &file_name, - |_| { - fs::rename(&temp_path, &displaced).expect("displace locked stale temp"); - fs::write(&temp_path, b"replacement must remain").expect("write replacement temp"); - fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) - .expect("set replacement permissions"); - }, - ) - .expect_err("rebound stale temp name must fail before unlink"); - assert!(error.contains("名称换绑"), "unexpected error: {error}"); - assert_eq!( - fs::read(&temp_path).expect("read replacement temp"), - b"replacement must remain" - ); - assert!(displaced.exists()); - } - - #[cfg(windows)] - #[test] - fn tool_plan_handoff_list_preserves_exclusively_open_windows_temp_file() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let storage = - open_windows_tool_plan_agent_storage(project.path(), &identity.agent_id, false) - .expect("open Windows handoff storage") - .expect("Windows handoff storage"); - let run_key = path_key(&identity.run_id); - let (temp_name, mut temp_file) = - create_windows_tool_plan_temp_file_at(&storage.agent_directory, &run_key) - .expect("create exclusive Windows temp"); - temp_file - .write_all(b"active atomic write") - .expect("write active Windows temp"); - temp_file.sync_data().expect("sync active Windows temp"); - let temp_path = - tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id) - .with_file_name(&temp_name); - - let error = list_at(project.path()).expect_err("exclusive temp must keep recovery busy"); - assert!( - temp_path.exists(), - "active Windows temp must remain: {error}" - ); - - drop(temp_file); - list_at(project.path()).expect("clean Windows temp after writer closes"); - assert!(!temp_path.exists()); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_list_detects_agent_directory_replacement_without_touching_external_files() - { - use std::os::unix::fs::symlink; - - let project = tempdir().expect("tool-plan handoff project"); - let external = tempdir().expect("external handoff directory"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let agent_directory = path - .parent() - .expect("handoff agent directory") - .to_path_buf(); - let handoff_root = agent_directory - .parent() - .expect("handoff root directory") - .to_path_buf(); - let displaced = handoff_root.join("displaced-agent-directory"); - let external_marker = external.path().join(format!( - ".{}.tmp.{}.1", - path.file_name() - .and_then(|value| value.to_str()) - .expect("handoff file name"), - i32::MAX, - )); - fs::write(&external_marker, b"must remain").expect("write external marker"); - fs::set_permissions(&external_marker, fs::Permissions::from_mode(0o600)) - .expect("set external marker permissions"); - - let error = list_at_unix_with_agent_open_hook(project.path(), |agent_key| { - if agent_key == path_key(&identity.agent_id) { - fs::rename(&agent_directory, &displaced).expect("displace opened agent directory"); - symlink(external.path(), &agent_directory) - .expect("replace agent directory with symlink"); - } - }) - .expect_err("replaced agent directory must fail closed"); - assert!(error.contains("发生替换"), "unexpected error: {error}"); - assert!(external_marker.exists()); - - fs::remove_file(&agent_directory).expect("remove replacement symlink"); - fs::rename(&displaced, &agent_directory).expect("restore agent directory"); - } - - #[test] - fn tool_plan_handoff_list_rejects_unknown_hash_paths_and_divergent_previous() { - let unknown_project = tempdir().expect("tool-plan handoff project"); - let unknown_identity = identity("loop-0-repair-0"); - write( - unknown_project.path(), - &unknown_identity, - 0, - &response("unknown", Vec::new()), - ); - let unknown_agent_directory = tool_plan_handoff_path( - unknown_project.path(), - &unknown_identity.agent_id, - &unknown_identity.run_id, - ) - .parent() - .expect("handoff agent directory") - .to_path_buf(); - fs::write(unknown_agent_directory.join("unexpected.txt"), b"unknown") - .expect("write unknown handoff entry"); - let error = list_at(unknown_project.path()).expect_err("unknown entry must fail closed"); - assert!(error.contains("未知文件"), "unexpected error: {error}"); - - let hash_project = tempdir().expect("tool-plan handoff project"); - let hash_identity = identity("loop-0-repair-0"); - let hash_path = tool_plan_handoff_path( - hash_project.path(), - &hash_identity.agent_id, - &hash_identity.run_id, - ); - write( - hash_project.path(), - &hash_identity, - 0, - &response("hash", Vec::new()), - ); - let hash_agent_directory = hash_path - .parent() - .expect("handoff agent directory") - .to_path_buf(); - let wrong_agent_directory = hash_agent_directory - .parent() - .expect("handoff root directory") - .join("0".repeat(64)); - fs::rename(&hash_agent_directory, &wrong_agent_directory) - .expect("move handoff under wrong hash"); - let error = list_at(hash_project.path()).expect_err("wrong hash path must fail closed"); - assert!(error.contains("hash 路径"), "unexpected error: {error}"); - - let primary_project = tempdir().expect("tool-plan handoff project"); - let conflicting_project = tempdir().expect("tool-plan handoff project"); - let conflict_identity = identity("loop-0-repair-0"); - write( - primary_project.path(), - &conflict_identity, - 0, - &response("primary", Vec::new()), - ); - write( - conflicting_project.path(), - &conflict_identity, - 0, - &response("divergent previous", Vec::new()), - ); - let primary_path = tool_plan_handoff_path( - primary_project.path(), - &conflict_identity.agent_id, - &conflict_identity.run_id, - ); - let conflicting_path = tool_plan_handoff_path( - conflicting_project.path(), - &conflict_identity.agent_id, - &conflict_identity.run_id, - ); - let previous_path = agent_runtime_json_sidecar_backup_path(&primary_path); - fs::copy(conflicting_path, &previous_path).expect("install divergent previous"); - #[cfg(unix)] - fs::set_permissions(&previous_path, fs::Permissions::from_mode(0o600)) - .expect("set divergent previous permissions"); - let error = list_at(primary_project.path()) - .expect_err("divergent primary and previous must fail closed"); - assert!(error.contains("primary/.previous 内容冲突")); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_list_only_cleans_0600_single_link_atomic_temp_files() { - let project = tempdir().expect("tool-plan handoff project"); - let identity = identity("loop-0-repair-0"); - write(project.path(), &identity, 0, &response("base", Vec::new())); - let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); - let bad_mode_temp = path.with_file_name(format!( - ".{}.tmp.42.100", - path.file_name() - .and_then(|value| value.to_str()) - .expect("handoff file name") - )); - fs::write(&bad_mode_temp, b"unsafe mode temp").expect("write bad mode temp"); - fs::set_permissions(&bad_mode_temp, fs::Permissions::from_mode(0o644)) - .expect("set bad temp permissions"); - let error = list_at(project.path()).expect_err("bad temp mode must fail closed"); - assert!(error.contains("0600")); - assert!(bad_mode_temp.exists()); - - fs::set_permissions(&bad_mode_temp, fs::Permissions::from_mode(0o600)) - .expect("repair temp permissions"); - let linked_temp = path.with_file_name(format!( - ".{}.tmp.42.101", - path.file_name() - .and_then(|value| value.to_str()) - .expect("handoff file name") - )); - fs::hard_link(&bad_mode_temp, &linked_temp).expect("hard link temp file"); - let error = list_at(project.path()).expect_err("linked temp must fail closed"); - assert!(error.contains("单链接")); - assert!(bad_mode_temp.exists()); - assert!(linked_temp.exists()); - } - - #[cfg(unix)] - #[test] - fn tool_plan_handoff_list_rejects_symlinked_root_agent_and_run_entries() { - use std::os::unix::fs::symlink; - - let root_project = tempdir().expect("tool-plan handoff project"); - let external_directory = tempdir().expect("external handoff directory"); - let handoff_root = root_project - .path() - .join(TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY); - fs::create_dir_all( - handoff_root - .parent() - .expect("tool-plan handoff runtime directory"), - ) - .expect("create runtime directory"); - symlink(external_directory.path(), &handoff_root).expect("symlink handoff root"); - let error = list_at(root_project.path()).expect_err("symlinked root must fail closed"); - assert!(error.contains("根目录"), "unexpected error: {error}"); - - let agent_project = tempdir().expect("tool-plan handoff project"); - let handoff_root = agent_project - .path() - .join(TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY); - fs::create_dir_all(&handoff_root).expect("create handoff root"); - symlink(external_directory.path(), handoff_root.join("a".repeat(64))) - .expect("symlink handoff agent"); - let error = list_at(agent_project.path()).expect_err("symlinked agent must fail closed"); - assert!(error.contains("Agent 目录"), "unexpected error: {error}"); - - let run_project = tempdir().expect("tool-plan handoff project"); - let run_identity = identity("loop-0-repair-0"); - write( - run_project.path(), - &run_identity, - 0, - &response("run symlink", Vec::new()), - ); - let run_path = tool_plan_handoff_path( - run_project.path(), - &run_identity.agent_id, - &run_identity.run_id, - ); - fs::remove_file(&run_path).expect("remove primary before symlink"); - let external_file = external_directory.path().join("external.json"); - fs::write(&external_file, b"{}").expect("write external file"); - symlink(&external_file, &run_path).expect("symlink handoff run"); - let error = list_at(run_project.path()).expect_err("symlinked run must fail closed"); - assert!(error.contains("账本文件"), "unexpected error: {error}"); - } -} +pub(crate) use discovery::list_at; +pub(crate) use ledger::{ + ensure_capacity_for_request_at, is_later_repair_identity, lookup_at, read_for_run_at, + remove_at, write_at, +}; +pub(crate) use model::{ + AgentRuntimeToolPlanHandoffEntry, AgentRuntimeToolPlanHandoffLedger, + AgentRuntimeToolPlanHandoffLookup, TOOL_PLAN_HANDOFF_SCHEMA_VERSION, +}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs new file mode 100644 index 000000000..86ddc767c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -0,0 +1,595 @@ +use std::collections::BTreeSet; +use std::path::Path; + +use crate::agent::redact_secret_tokens; +use crate::repository_context::redact_absolute_path_tokens; + +use super::identity_order_validation::{ + request_slot_for_attempt, validate_next_entry, validate_provider_request_id, + validate_short_metadata, validate_tool_plan_identity, +}; +use super::model::{ + AgentRuntimeToolPlanHandoffEntry, AgentRuntimeToolPlanHandoffLedger, + AgentRuntimeToolPlanHandoffResponse, TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES, + TOOL_PLAN_HANDOFF_FINISH_REASON_MAX_CHARS, TOOL_PLAN_HANDOFF_MAX_ENTRIES, + TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_SCHEMA_VERSION, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES, +}; +use super::storage_common::{response_fingerprint, validate_path_identity}; +use super::thinking::{decode_json_escaped_scan_view, normalize_thinking_for_persistence}; + +pub(super) fn validate_ledger( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, +) -> Result<(), String> { + if ledger.schema_version != TOOL_PLAN_HANDOFF_SCHEMA_VERSION { + return Err(format!( + "不支持的 tool-plan 成功响应交接版本:{}", + ledger.schema_version + )); + } + validate_path_identity(&ledger.agent_id, &ledger.run_id)?; + if ledger.entries.is_empty() || ledger.entries.len() > TOOL_PLAN_HANDOFF_MAX_ENTRIES { + return Err(format!( + "tool-plan 成功响应交接账本 entries 必须为 1..={TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条" + )); + } + + let mut provider_request_ids = BTreeSet::new(); + let mut previous = None; + for entry in &ledger.entries { + validate_entry(root, entry)?; + if entry.identity.agent_id != ledger.agent_id || entry.identity.run_id != ledger.run_id { + return Err("tool-plan 成功响应交接 entry 与账本 Agent/run 身份冲突".to_string()); + } + if !provider_request_ids.insert(entry.provider_request_id.as_str()) { + return Err("tool-plan 成功响应交接 providerRequestId 重复".to_string()); + } + match previous { + Some(previous) => validate_next_entry(previous, entry)?, + None if entry.repair_attempt != 0 => { + return Err("tool-plan 成功响应交接新 loop 首条必须为 repair-0".to_string()); + } + None => {} + } + previous = Some(entry); + } + Ok(()) +} + +pub(super) fn validate_entry( + root: &Path, + entry: &AgentRuntimeToolPlanHandoffEntry, +) -> Result<(), String> { + let (loop_iteration, repair_attempt) = validate_tool_plan_identity(&entry.identity)?; + if entry.loop_iteration != loop_iteration || entry.repair_attempt != repair_attempt { + return Err("tool-plan 成功响应交接 loop/repair 与 identity 不匹配".to_string()); + } + validate_provider_request_id(&entry.provider_request_id)?; + if entry.request_slot != request_slot_for_attempt(&entry.identity, entry.attempt) { + return Err("tool-plan 成功响应交接 requestSlot/attempt 无效".to_string()); + } + validate_response(root, &entry.response)?; + if entry.response_fingerprint != response_fingerprint(&entry.response)? { + return Err("tool-plan 成功响应交接 responseFingerprint 不匹配".to_string()); + } + if entry.created_at_ms == 0 { + return Err("tool-plan 成功响应交接 createdAtMs 无效".to_string()); + } + Ok(()) +} + +pub(super) fn validate_response( + root: &Path, + response: &AgentRuntimeToolPlanHandoffResponse, +) -> Result<(), String> { + validate_short_metadata( + root, + "model", + &response.model, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + false, + )?; + if response.text.len() > TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接 text 超过 {TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES} 字节上限" + )); + } + let persisted_thinking = normalize_thinking_for_persistence(&response.text); + if persisted_thinking.saw_wrapper + || !persisted_thinking.wrapper_valid + || !persisted_thinking.wrapper_balanced + || persisted_thinking.persisted_text != response.text + { + return Err("tool-plan 成功响应交接 text 仍包含 thinking block".to_string()); + } + match ( + response.thinking_wrapper_valid, + response.thinking_wrapper_balanced, + response.thinking_normalization_count, + response.thinking_source_text_chars, + response.thinking_source_text_sha256.as_deref(), + ) { + (true, true, 0, 0, None) => {} + (true, true, count, chars, Some(fingerprint)) + if count > 0 + && chars > 0 + && fingerprint.len() == 64 + && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} + (wrapper_valid, wrapper_balanced, _, chars, Some(fingerprint)) + if (!wrapper_valid || !wrapper_balanced) + && response.text.is_empty() + && chars > 0 + && fingerprint.len() == 64 + && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} + _ => return Err("tool-plan 成功响应交接 thinking normalization 元数据无效".to_string()), + } + validate_private_content(root, "text", &response.text, true)?; + if let Some(finish_reason) = response.finish_reason.as_deref() { + validate_short_metadata( + root, + "finishReason", + finish_reason, + TOOL_PLAN_HANDOFF_FINISH_REASON_MAX_CHARS, + true, + )?; + } + if let Some(response_id) = response.response_id.as_deref() { + validate_short_metadata( + root, + "responseId", + response_id, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + true, + )?; + } + if response.tool_calls.len() > TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS { + return Err(format!( + "tool-plan 成功响应交接 tool calls 超过 {TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS} 条上限" + )); + } + for call in &response.tool_calls { + validate_short_metadata( + root, + "tool call id", + &call.id, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + false, + )?; + validate_short_metadata( + root, + "tool call name", + &call.name, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + false, + )?; + if call.arguments.len() > TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接 arguments 超过 {TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES} 字节上限" + )); + } + validate_private_content(root, "arguments", &call.arguments, true)?; + } + if response.thinking_wrapper_valid + && response.thinking_wrapper_balanced + && response.text.trim().is_empty() + && response.tool_calls.is_empty() + { + return Err("tool-plan 成功响应交接响应不能为空".to_string()); + } + Ok(()) +} + +pub(super) fn validate_private_content( + root: &Path, + label: &str, + value: &str, + scan_json_escapes: bool, +) -> Result<(), String> { + validate_private_content_view(root, label, value)?; + validate_json_like_sensitive_keys(label, value)?; + let parsed = serde_json::from_str::(value); + if let Ok(json) = &parsed { + validate_json_sensitive_keys(label, json)?; + validate_json_absolute_path_inputs(label, json, None)?; + if let serde_json::Value::String(inner) = json { + validate_private_content_view(root, label, inner)?; + validate_json_like_sensitive_keys(label, inner)?; + if let Ok(inner_json) = serde_json::from_str::(inner) { + validate_json_sensitive_keys(label, &inner_json)?; + validate_json_absolute_path_inputs(label, &inner_json, None)?; + } + } + } else if scan_json_escapes { + let scan_view = decode_json_escaped_scan_view(value); + if scan_view != value { + validate_private_content_view(root, label, &scan_view)?; + validate_json_like_sensitive_keys(label, &scan_view)?; + if let Ok(json) = serde_json::from_str::(&scan_view) { + validate_json_sensitive_keys(label, &json)?; + validate_json_absolute_path_inputs(label, &json, None)?; + } + } + let json_like_payload = label == "arguments" + || value + .trim_start() + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b'{' | b'[')); + if json_like_payload { + validate_json_like_absolute_path_inputs(label, value)?; + if scan_view != value { + validate_json_like_absolute_path_inputs(label, &scan_view)?; + } + } + } + Ok(()) +} + +fn validate_private_content_view(_root: &Path, label: &str, value: &str) -> Result<(), String> { + let lower = value.to_ascii_lowercase(); + let sensitive_rule = [ + ".env", + "game-creator.config", + "authorization:", + "cookie:", + "bearer ", + ] + .into_iter() + .position(|marker| lower.contains(marker)) + .or_else(|| (redact_secret_tokens(value) != value).then_some(5)); + if let Some(rule) = sensitive_rule { + return Err(format!( + "tool-plan 成功响应交接 {label} 命中敏感规则 #{rule}" + )); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(format!("tool-plan 成功响应交接 {label} 包含不安全控制字符")); + } + Ok(()) +} + +fn validate_json_sensitive_keys(label: &str, value: &serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::Array(values) => { + for value in values { + validate_json_sensitive_keys(label, value)?; + } + } + serde_json::Value::Object(values) => { + for (key, value) in values { + if is_sensitive_json_key(key) { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); + } + validate_json_sensitive_keys(label, value)?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_json_like_sensitive_keys(label: &str, value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'"' | b'\'' => { + let quote = bytes[index]; + let Some((key, next_index)) = decode_json_like_quoted_token(value, index, quote) + else { + index += 1; + continue; + }; + index = next_index; + let separator = skip_json_like_trivia(value, index); + if bytes.get(separator) == Some(&b':') && is_sensitive_json_key(&key) { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); + } + } + byte if is_json_like_key_byte(byte) => { + let start = index; + while bytes.get(index).copied().is_some_and(is_json_like_key_byte) { + index += 1; + } + let separator = skip_json_like_trivia(value, index); + if bytes.get(separator) == Some(&b':') + && is_sensitive_json_key(&value[start..index]) + { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); + } + } + _ => index += 1, + } + } + Ok(()) +} + +fn decode_json_like_quoted_token(value: &str, start: usize, quote: u8) -> Option<(String, usize)> { + let bytes = value.as_bytes(); + let mut output = String::new(); + let mut index = start.checked_add(1)?; + while index < bytes.len() { + match bytes[index] { + byte if byte == quote => return Some((output, index + 1)), + b'\\' => { + let escaped = *bytes.get(index + 1)?; + if escaped == b'u' { + let decoded = index + .checked_add(6) + .and_then(|end| value.get(index + 2..end)) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32); + if let Some(character) = decoded { + output.push(character); + index += 6; + } else { + output.push('\\'); + output.push('u'); + index += 2; + } + } else { + if let Some(character) = match escaped { + b'"' => Some('"'), + b'\'' => Some('\''), + b'\\' => Some('\\'), + b'/' => Some('/'), + b'b' => Some('\u{0008}'), + b'f' => Some('\u{000c}'), + b'n' => Some('\n'), + b'r' => Some('\r'), + b't' => Some('\t'), + _ => None, + } { + output.push(character); + } else { + output.push('\\'); + output.push(escaped as char); + } + index += 2; + } + } + _ => { + let character = value[index..].chars().next()?; + output.push(character); + index += character.len_utf8(); + } + } + } + None +} + +fn is_json_like_key_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') +} + +fn skip_json_like_trivia(value: &str, mut index: usize) -> usize { + let bytes = value.as_bytes(); + loop { + while bytes + .get(index) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + index += 1; + } + if bytes.get(index..index.saturating_add(2)) == Some(b"//") { + index += 2; + while bytes + .get(index) + .is_some_and(|byte| !matches!(byte, b'\n' | b'\r')) + { + index += 1; + } + continue; + } + if bytes.get(index..index.saturating_add(2)) == Some(b"/*") { + index += 2; + while index < bytes.len() && bytes.get(index..index.saturating_add(2)) != Some(b"*/") { + index += 1; + } + if index >= bytes.len() { + return bytes.len(); + } + index += 2; + continue; + } + return index; + } +} + +pub(super) fn is_sensitive_json_key(key: &str) -> bool { + let normalized = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + [ + "xapikey", + "apikey", + "accesstoken", + "refreshtoken", + "password", + "clientsecret", + "privatekey", + "credential", + "authorization", + "cookie", + "secret", + ] + .into_iter() + .any(|sensitive| normalized.contains(sensitive)) + || ["token", "tokens"] + .into_iter() + .any(|sensitive| normalized == sensitive || normalized.ends_with(sensitive)) +} + +fn validate_json_absolute_path_inputs( + label: &str, + value: &serde_json::Value, + parent_key: Option<&str>, +) -> Result<(), String> { + match value { + serde_json::Value::String(value) => { + if !parent_key.is_some_and(is_tool_plan_content_field) + && redact_absolute_path_tokens(value) != *value + { + return Err(format!( + "tool-plan 成功响应交接 {label} 的结构化输入包含绝对路径" + )); + } + } + serde_json::Value::Array(values) => { + for value in values { + validate_json_absolute_path_inputs(label, value, parent_key)?; + } + } + serde_json::Value::Object(values) => { + for (key, value) in values { + validate_json_absolute_path_inputs(label, value, Some(key))?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_json_like_absolute_path_inputs(label: &str, value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + let (key, next_index) = match bytes[index] { + b'"' | b'\'' => { + let quote = bytes[index]; + let Some(decoded) = decode_json_like_quoted_token(value, index, quote) else { + index += 1; + continue; + }; + decoded + } + byte if is_json_like_key_byte(byte) => { + let start = index; + while bytes.get(index).copied().is_some_and(is_json_like_key_byte) { + index += 1; + } + (value[start..index].to_string(), index) + } + _ => { + index += 1; + continue; + } + }; + index = next_index; + let separator = skip_json_like_trivia(value, index); + if bytes.get(separator) != Some(&b':') { + continue; + } + let value_start = skip_json_like_trivia(value, separator + 1); + let content_field = is_tool_plan_content_field(&key); + match bytes.get(value_start).copied() { + Some(quote @ (b'"' | b'\'')) => { + if let Some((candidate, next_value)) = + decode_json_like_quoted_token(value, value_start, quote) + { + if !content_field && redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的 JSON-like 输入包含绝对路径" + )); + } + index = next_value; + } else if !content_field { + let candidate = decode_json_escaped_scan_view( + value.get(value_start + 1..).unwrap_or_default(), + ); + if redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的未闭合 JSON-like 字符串包含绝对路径" + )); + } + } + } + Some(b'[') if !content_field => { + let mut array_index = value_start + 1; + while let Some(byte) = bytes.get(array_index).copied() { + if byte == b']' { + break; + } + if matches!(byte, b'"' | b'\'') { + if let Some((candidate, next_value)) = + decode_json_like_quoted_token(value, array_index, byte) + { + if redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的 JSON-like 数组包含绝对路径" + )); + } + array_index = next_value; + continue; + } else { + let candidate = decode_json_escaped_scan_view( + value.get(array_index + 1..).unwrap_or_default(), + ); + if redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的未闭合 JSON-like 数组包含绝对路径" + )); + } + break; + } + } + array_index += 1; + } + } + Some(_) if !content_field => { + let end = bytes[value_start..] + .iter() + .position(|byte| { + byte.is_ascii_whitespace() || matches!(byte, b',' | b'}' | b']') + }) + .map(|offset| value_start + offset) + .unwrap_or(bytes.len()); + let candidate = &value[value_start..end]; + if redact_absolute_path_tokens(candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的 JSON-like 输入包含绝对路径" + )); + } + } + _ => {} + } + } + Ok(()) +} + +fn is_tool_plan_content_field(key: &str) -> bool { + let key = key.to_ascii_lowercase(); + [ + "body", + "code", + "content", + "css", + "detail", + "explanation", + "html", + "instruction", + "message", + "newtext", + "oldtext", + "patch", + "plan", + "prompt", + "query", + "reason", + "response", + "script", + "summary", + "task", + "text", + "thinkingsummary", + "step", + "title", + ] + .contains(&key.as_str()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/discovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/discovery.rs new file mode 100644 index 000000000..77945eeef --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/discovery.rs @@ -0,0 +1,289 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use super::model::{ + AgentRuntimeToolPlanHandoffLedger, TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS, + TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES, TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS, +}; +use super::storage_common::is_handoff_path_key; +#[cfg(unix)] +use super::storage_unix::{ + lock_unix_tool_plan_directory, open_unix_tool_plan_directory_at, open_unix_tool_plan_root, + read_unix_discovered_ledger_file, read_unix_tool_plan_directory_names, + remove_stale_unix_handoff_temp_file_at, verify_unix_tool_plan_entry, + verify_unix_tool_plan_root, +}; +#[cfg(windows)] +use super::storage_windows::list_at_windows; + +#[cfg(unix)] +pub(crate) fn list_at(root: &Path) -> Result, String> { + list_at_unix_with_agent_open_hook(root, |_| {}) +} + +#[cfg(unix)] +pub(super) fn list_at_unix_with_agent_open_hook( + root: &Path, + mut after_agent_open: F, +) -> Result, String> +where + F: FnMut(&str), +{ + let project_directory = open_unix_tool_plan_root(root)?; + let Some(agent_runtime_directory) = + open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? + else { + return Ok(Vec::new()); + }; + let Some(runtime_directory) = open_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + else { + return Ok(Vec::new()); + }; + let Some(handoff_directory) = open_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + else { + return Ok(Vec::new()); + }; + lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; + + let agent_names = + read_unix_tool_plan_directory_names(&handoff_directory, "tool-plan 成功响应交接根目录")?; + if agent_names.len() > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS} 个 Agent 上限" + )); + } + let mut discovered = BTreeMap::<(String, String), DiscoveredToolPlanHandoffLedgers>::new(); + let mut discovered_file_count = 0usize; + for agent_key in agent_names { + if !is_handoff_path_key(&agent_key) { + return Err(format!( + "tool-plan 成功响应交接 Agent 目录名不是规范 hash:{agent_key}" + )); + } + let agent_directory = open_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + .ok_or_else(|| "tool-plan 成功响应交接 Agent 目录在扫描期间消失".to_string())?; + lock_unix_tool_plan_directory(&agent_directory, "tool-plan 成功响应交接 Agent 目录")?; + after_agent_open(&agent_key); + let run_names = read_unix_tool_plan_directory_names( + &agent_directory, + "tool-plan 成功响应交接 Agent 目录", + )?; + if run_names.len() > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + || discovered_file_count.saturating_add(run_names.len()) + > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES} 个文件上限" + )); + } + discovered_file_count = discovered_file_count.saturating_add(run_names.len()); + for file_name in run_names { + match classify_handoff_file_name(&file_name) { + Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) => { + let ledger = read_unix_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.primary.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 primary 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) => { + let ledger = read_unix_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.previous.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 .previous 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => { + if !is_handoff_path_key(run_key) { + return Err(format!( + "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" + )); + } + remove_stale_unix_handoff_temp_file_at(&agent_directory, &file_name)?; + } + None => { + return Err(format!( + "tool-plan 成功响应交接目录包含未知文件:{file_name}" + )); + } + } + } + verify_unix_tool_plan_entry( + &handoff_directory, + &agent_key, + &agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + } + verify_unix_tool_plan_entry( + &runtime_directory, + "tool-plan-handoffs", + &handoff_directory, + true, + "tool-plan 成功响应交接根目录", + )?; + verify_unix_tool_plan_entry( + &agent_runtime_directory, + "runtime", + &runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_unix_tool_plan_entry( + &project_directory, + ".agent", + &agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_unix_tool_plan_root(root, &project_directory)?; + + let mut ledgers = Vec::with_capacity(discovered.len()); + for ((agent_key, run_key), files) in discovered { + let selected = match (files.primary, files.previous) { + (Some(primary), Some(previous)) => { + if !ledger_is_prefix(&previous, &primary) { + return Err(format!( + "tool-plan 成功响应交接 primary/.previous 内容冲突:{agent_key}/{run_key}" + )); + } + primary + } + (Some(primary), None) => primary, + (None, Some(previous)) => previous, + (None, None) => continue, + }; + ledgers.push(selected); + } + ledgers.sort_by(|left, right| { + left.agent_id + .cmp(&right.agent_id) + .then_with(|| left.run_id.cmp(&right.run_id)) + }); + Ok(ledgers) +} + +#[cfg(windows)] +pub(crate) fn list_at(root: &Path) -> Result, String> { + list_at_windows(root) +} + +#[derive(Default)] +pub(super) struct DiscoveredToolPlanHandoffLedgers { + pub(super) primary: Option, + pub(super) previous: Option, +} + +pub(super) fn discovered_handoff_ledgers_mut<'a>( + discovered: &'a mut BTreeMap<(String, String), DiscoveredToolPlanHandoffLedgers>, + agent_key: &str, + run_key: &str, +) -> Result<&'a mut DiscoveredToolPlanHandoffLedgers, String> { + if !is_handoff_path_key(run_key) { + return Err(format!( + "tool-plan 成功响应交接 run 文件名不是规范 hash:{run_key}" + )); + } + let key = (agent_key.to_string(), run_key.to_string()); + if !discovered.contains_key(&key) + && discovered.len() >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS + { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS} 条账本上限" + )); + } + Ok(discovered.entry(key).or_default()) +} + +pub(super) enum DiscoveredToolPlanHandoffFileName<'a> { + Primary(&'a str), + Previous(&'a str), + Temporary { run_key: &'a str }, +} + +pub(super) fn classify_handoff_file_name( + file_name: &str, +) -> Option> { + if let Some(run_key) = file_name.strip_suffix(".json") { + return Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)); + } + if let Some(run_key) = file_name + .strip_prefix('.') + .and_then(|value| value.strip_suffix(".json.previous")) + { + return Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)); + } + let temporary = file_name.strip_prefix('.')?; + let (run_key, suffix) = temporary.split_once(".json.tmp.")?; + let (pid, nanos) = suffix.split_once('.')?; + if pid.is_empty() + || nanos.is_empty() + || !pid.bytes().all(|byte| byte.is_ascii_digit()) + || !nanos.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + if pid.parse::().ok()? == 0 { + return None; + } + Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) +} + +fn ledger_is_prefix( + previous: &AgentRuntimeToolPlanHandoffLedger, + primary: &AgentRuntimeToolPlanHandoffLedger, +) -> bool { + previous.schema_version == primary.schema_version + && previous.agent_id == primary.agent_id + && previous.run_id == primary.run_id + && previous.entries.len() <= primary.entries.len() + && previous.entries == primary.entries[..previous.entries.len()] +} + +pub(super) fn select_primary_and_previous( + agent_key: &str, + run_key: &str, + primary: Option, + previous: Option, +) -> Result, String> { + match (primary, previous) { + (Some(primary), Some(previous)) => { + if !ledger_is_prefix(&previous, &primary) { + return Err(format!( + "tool-plan 成功响应交接 primary/.previous 内容冲突:{agent_key}/{run_key}" + )); + } + Ok(Some(primary)) + } + (Some(primary), None) => Ok(Some(primary)), + (None, Some(previous)) => Ok(Some(previous)), + (None, None) => Ok(None), + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs new file mode 100644 index 000000000..806ac6e8d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/identity_order_validation.rs @@ -0,0 +1,201 @@ +use std::path::Path; + +use crate::provider_retry::{validate_identity, AgentRuntimeProviderRetryIdentity}; +use crate::repository_context::redact_absolute_path_tokens; + +use super::content_validation::{is_sensitive_json_key, validate_private_content}; +use super::model::{ + AgentRuntimeToolPlanHandoffEntry, TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS, + TOOL_PLAN_HANDOFF_REQUEST_ID_MAX_CHARS, +}; + +pub(super) fn validate_tool_plan_identity( + identity: &AgentRuntimeProviderRetryIdentity, +) -> Result<(u64, u32), String> { + validate_identity(identity)?; + if identity.request_kind != "tool-plan" || identity.allow_idle_context_compaction { + return Err("tool-plan 成功响应交接 identity requestKind/compaction 无效".to_string()); + } + for (label, value) in [ + ("projectId", identity.project_id.as_str()), + ("agentId", identity.agent_id.as_str()), + ("taskId", identity.task_id.as_str()), + ("sessionId", identity.session_id.as_str()), + ("runId", identity.run_id.as_str()), + ("source", identity.source.as_str()), + ] { + validate_short_text( + label, + value, + TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS, + false, + )?; + } + if let Some(goal_id) = identity.goal_id.as_deref() { + validate_short_text( + "goalId", + goal_id, + TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS, + false, + )?; + } + parse_tool_plan_base_request_slot(&identity.base_request_slot) +} + +pub(super) fn parse_tool_plan_base_request_slot(value: &str) -> Result<(u64, u32), String> { + let Some(rest) = value.strip_prefix("loop-") else { + return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); + }; + let Some((loop_text, repair_text)) = rest.split_once("-repair-") else { + return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); + }; + if loop_text.is_empty() + || repair_text.is_empty() + || !loop_text.bytes().all(|byte| byte.is_ascii_digit()) + || !repair_text.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); + } + let loop_iteration = loop_text + .parse::() + .map_err(|_| "tool-plan 成功响应交接 loopIteration 溢出".to_string())?; + let repair_attempt = repair_text + .parse::() + .map_err(|_| "tool-plan 成功响应交接 repairAttempt 溢出".to_string())?; + if value != format!("loop-{loop_iteration}-repair-{repair_attempt}") { + return Err("tool-plan 成功响应交接 baseRequestSlot 非规范格式".to_string()); + } + Ok((loop_iteration, repair_attempt)) +} + +pub(super) fn validate_next_entry( + previous: &AgentRuntimeToolPlanHandoffEntry, + candidate: &AgentRuntimeToolPlanHandoffEntry, +) -> Result<(), String> { + if !same_durable_tool_plan_run(&previous.identity, &candidate.identity) { + return Err("tool-plan 成功响应交接 entry 的 durable run 身份冲突".to_string()); + } + if candidate.loop_iteration == previous.loop_iteration { + if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) { + return Err("tool-plan 成功响应交接同 loop repair 链身份冲突".to_string()); + } + let expected_repair = previous + .repair_attempt + .checked_add(1) + .ok_or_else(|| "tool-plan 成功响应交接 repairAttempt 溢出".to_string())?; + if candidate.repair_attempt != expected_repair { + return Err("tool-plan 成功响应交接同 loop 的 repair 必须连续追加".to_string()); + } + return Ok(()); + } + if candidate.loop_iteration > previous.loop_iteration && candidate.repair_attempt == 0 { + return Ok(()); + } + Err("tool-plan 成功响应交接 entry 顺序无效,新 loop 必须从 repair-0 开始".to_string()) +} + +fn same_durable_tool_plan_run( + current: &AgentRuntimeProviderRetryIdentity, + candidate: &AgentRuntimeProviderRetryIdentity, +) -> bool { + current.project_id == candidate.project_id + && current.agent_id == candidate.agent_id + && current.task_id == candidate.task_id + && current.session_id == candidate.session_id + && current.run_id == candidate.run_id + && current.source == candidate.source + && current.request_kind == candidate.request_kind +} + +pub(super) fn entry_payload_matches( + left: &AgentRuntimeToolPlanHandoffEntry, + right: &AgentRuntimeToolPlanHandoffEntry, +) -> bool { + left.identity == right.identity + && left.provider_request_id == right.provider_request_id + && left.request_slot == right.request_slot + && left.attempt == right.attempt + && left.loop_iteration == right.loop_iteration + && left.repair_attempt == right.repair_attempt + && left.response == right.response + && left.response_fingerprint == right.response_fingerprint +} + +pub(super) fn same_tool_plan_repair_chain( + current: &AgentRuntimeProviderRetryIdentity, + candidate: &AgentRuntimeProviderRetryIdentity, +) -> bool { + current.project_id == candidate.project_id + && current.agent_id == candidate.agent_id + && current.task_id == candidate.task_id + && current.session_id == candidate.session_id + && current.run_id == candidate.run_id + && current.source == candidate.source + && current.goal_id == candidate.goal_id + && current.goal_revision == candidate.goal_revision + && current.goal_snapshot_fingerprint == candidate.goal_snapshot_fingerprint + && current.applied_steer_cursor == candidate.applied_steer_cursor + && current.request_kind == candidate.request_kind + && current.provider_config_fingerprint == candidate.provider_config_fingerprint + && current.allow_idle_context_compaction == candidate.allow_idle_context_compaction +} + +pub(super) fn request_slot_for_attempt( + identity: &AgentRuntimeProviderRetryIdentity, + attempt: u32, +) -> String { + if attempt == 0 { + identity.base_request_slot.clone() + } else { + format!("{}-transient-{attempt}", identity.base_request_slot) + } +} + +pub(super) fn validate_provider_request_id(value: &str) -> Result<(), String> { + validate_short_text( + "providerRequestId", + value, + TOOL_PLAN_HANDOFF_REQUEST_ID_MAX_CHARS, + false, + )?; + let fingerprint = value + .strip_prefix("provider-request-") + .ok_or_else(|| "tool-plan 成功响应交接 providerRequestId 无效".to_string())?; + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("tool-plan 成功响应交接 providerRequestId 无效".to_string()); + } + Ok(()) +} + +fn validate_short_text( + label: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), String> { + if (!allow_empty && value.trim().is_empty()) + || value.chars().count() > max_chars + || value.chars().any(char::is_control) + { + return Err(format!("tool-plan 成功响应交接 {label} 无效")); + } + Ok(()) +} + +pub(super) fn validate_short_metadata( + root: &Path, + label: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), String> { + validate_short_text(label, value, max_chars, allow_empty)?; + validate_private_content(root, label, value, true)?; + if is_sensitive_json_key(value) { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感元数据")); + } + if redact_absolute_path_tokens(value) != value { + return Err(format!("tool-plan 成功响应交接 {label} 包含绝对路径")); + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs new file mode 100644 index 000000000..99dbec362 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/ledger.rs @@ -0,0 +1,267 @@ +use std::path::Path; + +use platform_llm::LlmRunResponse; + +use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity}; + +use super::content_validation::{validate_entry, validate_ledger, validate_response}; +use super::identity_order_validation::{ + entry_payload_matches, parse_tool_plan_base_request_slot, same_tool_plan_repair_chain, + validate_next_entry, validate_provider_request_id, validate_tool_plan_identity, +}; +use super::model::{ + AgentRuntimeToolPlanHandoffEntry, AgentRuntimeToolPlanHandoffLedger, + AgentRuntimeToolPlanHandoffLookup, AgentRuntimeToolPlanHandoffResponse, + AgentRuntimeToolPlanHandoffToolCall, AgentRuntimeToolPlanHandoffUsage, + TOOL_PLAN_HANDOFF_MAX_ENTRIES, TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, + TOOL_PLAN_HANDOFF_SCHEMA_VERSION, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, +}; +use super::storage_common::{response_fingerprint, validate_path_identity, write_ledger_at}; +#[cfg(unix)] +use super::storage_unix::{read_for_run_at_unix, remove_at_unix_with_agent_open_hook}; +#[cfg(windows)] +use super::storage_windows::{read_for_run_at_windows, remove_at_windows}; +use super::thinking::normalize_thinking_for_persistence; + +pub(crate) fn read_for_run_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + validate_path_identity(agent_id, run_id)?; + #[cfg(unix)] + { + return read_for_run_at_unix(root, agent_id, run_id); + } + #[cfg(windows)] + { + return read_for_run_at_windows(root, agent_id, run_id); + } + #[cfg(not(any(unix, windows)))] + { + let _ = root; + Err("当前平台不支持安全 tool-plan 成功响应交接存储".to_string()) + } +} + +pub(crate) fn lookup_at( + root: &Path, + agent_id: &str, + run_id: &str, + identity: &AgentRuntimeProviderRetryIdentity, +) -> Result { + validate_tool_plan_identity(identity)?; + validate_path_identity(agent_id, run_id)?; + if identity.agent_id != agent_id || identity.run_id != run_id { + return Err("tool-plan 成功响应交接查询身份与路径 Agent/run 冲突".to_string()); + } + let (loop_iteration, repair_attempt) = + parse_tool_plan_base_request_slot(&identity.base_request_slot)?; + let Some(ledger) = read_for_run_at(root, agent_id, run_id)? else { + return Ok(AgentRuntimeToolPlanHandoffLookup::Missing); + }; + let target = (loop_iteration, repair_attempt); + let Some(entry) = ledger.entries.iter().find(|entry| { + entry.loop_iteration == loop_iteration && entry.repair_attempt == repair_attempt + }) else { + if ledger + .entries + .iter() + .any(|entry| (entry.loop_iteration, entry.repair_attempt) > target) + { + return Err("tool-plan 成功响应交接账本包含当前请求之后的未来 entry".to_string()); + } + return Ok(AgentRuntimeToolPlanHandoffLookup::Missing); + }; + if entry.identity == *identity { + Ok(AgentRuntimeToolPlanHandoffLookup::Exact(entry.clone())) + } else { + Ok(AgentRuntimeToolPlanHandoffLookup::IdentityConflict( + entry.clone(), + )) + } +} + +pub(crate) fn ensure_capacity_for_request_at( + root: &Path, + agent_id: &str, + run_id: &str, + identity: &AgentRuntimeProviderRetryIdentity, +) -> Result<(), String> { + validate_tool_plan_identity(identity)?; + validate_path_identity(agent_id, run_id)?; + if identity.agent_id != agent_id || identity.run_id != run_id { + return Err("tool-plan 请求前容量门禁与路径 Agent/run 身份冲突".to_string()); + } + let target = parse_tool_plan_base_request_slot(&identity.base_request_slot)?; + let Some(ledger) = read_for_run_at(root, agent_id, run_id)? else { + return Ok(()); + }; + if ledger.entries.iter().any(|entry| { + (entry.loop_iteration, entry.repair_attempt) == target && entry.identity == *identity + }) { + return Ok(()); + } + if ledger.entries.len() >= TOOL_PLAN_HANDOFF_MAX_ENTRIES { + return Err(format!( + "tool-plan 请求前账本容量已耗尽:同一 run 最多 {TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条成功响应" + )); + } + let current_bytes = serde_json::to_vec_pretty(&ledger) + .map_err(|error| format!("序列化 tool-plan 请求前容量快照失败:{error}"))? + .len() + .saturating_add(1); + if current_bytes.saturating_add(TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES) + > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + { + return Err(format!( + "tool-plan 请求前账本剩余空间不足:至少需要预留 {TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES} 字节" + )); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn write_at( + root: &Path, + identity: &AgentRuntimeProviderRetryIdentity, + request_slot: &str, + attempt: u32, + provider_request_id: &str, + response: &LlmRunResponse, +) -> Result { + validate_tool_plan_identity(identity)?; + validate_provider_request_id(provider_request_id)?; + let (loop_iteration, repair_attempt) = + parse_tool_plan_base_request_slot(&identity.base_request_slot)?; + let response = response_for_persistence(root, response)?; + let response_fingerprint = response_fingerprint(&response)?; + let entry = AgentRuntimeToolPlanHandoffEntry { + identity: identity.clone(), + provider_request_id: provider_request_id.to_string(), + request_slot: request_slot.to_string(), + attempt, + loop_iteration, + repair_attempt, + response, + response_fingerprint, + created_at_ms: provider_retry::now_ms(), + }; + validate_entry(root, &entry)?; + + let mut ledger = match read_for_run_at(root, &identity.agent_id, &identity.run_id)? { + Some(mut ledger) => { + if let Some(existing) = ledger.entries.iter().find(|existing| { + existing.loop_iteration == loop_iteration + && existing.repair_attempt == repair_attempt + }) { + if entry_payload_matches(existing, &entry) { + return Ok(existing.clone()); + } + return Err("tool-plan 成功响应交接同一 slot 内容冲突".to_string()); + } + if ledger.entries.len() >= TOOL_PLAN_HANDOFF_MAX_ENTRIES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条上限" + )); + } + let previous = ledger + .entries + .last() + .ok_or_else(|| "tool-plan 成功响应交接账本不能为空".to_string())?; + validate_next_entry(previous, &entry)?; + ledger.entries.push(entry.clone()); + ledger + } + None => { + if repair_attempt != 0 { + return Err("tool-plan 成功响应交接新 loop 首条必须为 repair-0".to_string()); + } + AgentRuntimeToolPlanHandoffLedger { + schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), + agent_id: identity.agent_id.clone(), + run_id: identity.run_id.clone(), + entries: vec![entry.clone()], + } + } + }; + validate_ledger(root, &ledger)?; + + write_ledger_at(root, &ledger)?; + let persisted = read_for_run_at(root, &identity.agent_id, &identity.run_id)? + .ok_or_else(|| "tool-plan 成功响应交接账本写入后不存在".to_string())?; + if persisted != ledger { + return Err("tool-plan 成功响应交接账本写入后内容冲突".to_string()); + } + ledger = persisted; + ledger + .entries + .into_iter() + .find(|persisted_entry| { + persisted_entry.loop_iteration == loop_iteration + && persisted_entry.repair_attempt == repair_attempt + }) + .ok_or_else(|| "tool-plan 成功响应交接 entry 写入后不存在".to_string()) +} + +pub(crate) fn remove_at(root: &Path, agent_id: &str, run_id: &str) -> Result<(), String> { + validate_path_identity(agent_id, run_id)?; + #[cfg(unix)] + { + return remove_at_unix_with_agent_open_hook(root, agent_id, run_id, |_| {}); + } + #[cfg(windows)] + { + return remove_at_windows(root, agent_id, run_id); + } + #[cfg(not(any(unix, windows)))] + { + let _ = root; + Err("当前平台不支持安全 tool-plan 成功响应交接删除".to_string()) + } +} + +pub(crate) fn is_later_repair_identity( + current: &AgentRuntimeProviderRetryIdentity, + candidate: &AgentRuntimeProviderRetryIdentity, +) -> bool { + let Ok((current_loop, current_repair)) = validate_tool_plan_identity(current) else { + return false; + }; + let Ok((candidate_loop, candidate_repair)) = validate_tool_plan_identity(candidate) else { + return false; + }; + current_loop == candidate_loop + && candidate_repair > current_repair + && same_tool_plan_repair_chain(current, candidate) +} + +pub(super) fn response_for_persistence( + root: &Path, + response: &LlmRunResponse, +) -> Result { + let thinking = normalize_thinking_for_persistence(&response.text); + let persisted = AgentRuntimeToolPlanHandoffResponse { + provider: response.provider, + model: response.model.clone(), + text: thinking.persisted_text, + thinking_wrapper_valid: thinking.wrapper_valid, + thinking_wrapper_balanced: thinking.wrapper_balanced, + thinking_normalization_count: thinking.complete_block_count, + thinking_source_text_chars: thinking.source_text_chars, + thinking_source_text_sha256: thinking.source_text_sha256, + finish_reason: response.finish_reason.clone(), + response_id: response.response_id.clone(), + usage: response + .usage + .as_ref() + .map(AgentRuntimeToolPlanHandoffUsage::from), + tool_calls: response + .tool_calls + .iter() + .map(AgentRuntimeToolPlanHandoffToolCall::from) + .collect(), + }; + validate_response(root, &persisted)?; + Ok(persisted) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs new file mode 100644 index 000000000..84b0b3098 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs @@ -0,0 +1,183 @@ +use platform_llm::{LlmProvider, LlmRunResponse, LlmTokenUsage, LlmToolCall}; +use serde::{Deserialize, Serialize}; + +use crate::provider_retry::AgentRuntimeProviderRetryIdentity; + +pub(crate) const TOOL_PLAN_HANDOFF_SCHEMA_VERSION: &str = "game-creator-tool-plan-handoff.v1"; + +#[cfg(test)] +pub(super) const TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY: &str = ".agent/runtime/tool-plan-handoffs"; +pub(super) const TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; +pub(super) const TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES: usize = 512 * 1024; +pub(super) const TOOL_PLAN_HANDOFF_MAX_ENTRIES: usize = 128; +pub(super) const TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS: usize = 32; +pub(super) const TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES: usize = 256 * 1024; +pub(super) const TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES: usize = 256 * 1024; +pub(super) const TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS: usize = 256; +pub(super) const TOOL_PLAN_HANDOFF_FINISH_REASON_MAX_CHARS: usize = 80; +pub(super) const TOOL_PLAN_HANDOFF_REQUEST_ID_MAX_CHARS: usize = 256; +pub(super) const TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS: usize = 512; +pub(super) const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS: usize = 1024; +pub(super) const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS: usize = 1024; +pub(super) const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES: usize = 4096; +#[cfg(test)] +pub(super) const TOOL_PLAN_HANDOFF_LABEL: &str = "Agent Runtime tool-plan 成功响应交接账本"; +pub(super) const INVALID_THINKING_OPEN_MARKER: &str = ""; +pub(super) const INVALID_THINKING_CLOSE_MARKER: &str = ""; +pub(super) static TOOL_PLAN_HANDOFF_TEMP_NONCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(super) struct AgentRuntimeToolPlanHandoffUsage { + prompt_tokens: u64, + completion_tokens: u64, + total_tokens: u64, +} + +impl From<&LlmTokenUsage> for AgentRuntimeToolPlanHandoffUsage { + fn from(value: &LlmTokenUsage) -> Self { + Self { + prompt_tokens: value.prompt_tokens, + completion_tokens: value.completion_tokens, + total_tokens: value.total_tokens, + } + } +} + +impl From<&AgentRuntimeToolPlanHandoffUsage> for LlmTokenUsage { + fn from(value: &AgentRuntimeToolPlanHandoffUsage) -> Self { + Self { + prompt_tokens: value.prompt_tokens, + completion_tokens: value.completion_tokens, + total_tokens: value.total_tokens, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(super) struct AgentRuntimeToolPlanHandoffToolCall { + pub(super) id: String, + pub(super) name: String, + pub(super) arguments: String, +} + +impl From<&LlmToolCall> for AgentRuntimeToolPlanHandoffToolCall { + fn from(value: &LlmToolCall) -> Self { + Self { + id: value.id.clone(), + name: value.name.clone(), + arguments: value.arguments.clone(), + } + } +} + +impl From<&AgentRuntimeToolPlanHandoffToolCall> for LlmToolCall { + fn from(value: &AgentRuntimeToolPlanHandoffToolCall) -> Self { + Self { + id: value.id.clone(), + name: value.name.clone(), + arguments: value.arguments.clone(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(super) struct AgentRuntimeToolPlanHandoffResponse { + pub(super) provider: LlmProvider, + pub(super) model: String, + pub(super) text: String, + pub(super) thinking_wrapper_valid: bool, + pub(super) thinking_wrapper_balanced: bool, + pub(super) thinking_normalization_count: u32, + pub(super) thinking_source_text_chars: usize, + pub(super) thinking_source_text_sha256: Option, + pub(super) finish_reason: Option, + pub(super) response_id: Option, + pub(super) usage: Option, + pub(super) tool_calls: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeToolPlanHandoffEntry { + pub(crate) identity: AgentRuntimeProviderRetryIdentity, + pub(crate) provider_request_id: String, + pub(crate) request_slot: String, + pub(crate) attempt: u32, + pub(crate) loop_iteration: u64, + pub(crate) repair_attempt: u32, + pub(super) response: AgentRuntimeToolPlanHandoffResponse, + pub(crate) response_fingerprint: String, + pub(super) created_at_ms: u64, +} + +impl AgentRuntimeToolPlanHandoffEntry { + pub(crate) fn to_llm_response(&self) -> LlmRunResponse { + let text = if !self.response.thinking_wrapper_valid { + INVALID_THINKING_CLOSE_MARKER.to_string() + } else if !self.response.thinking_wrapper_balanced { + INVALID_THINKING_OPEN_MARKER.to_string() + } else { + self.response.text.clone() + }; + LlmRunResponse { + provider: self.response.provider, + model: self.response.model.clone(), + text, + finish_reason: self.response.finish_reason.clone(), + response_id: self.response.response_id.clone(), + usage: self.response.usage.as_ref().map(LlmTokenUsage::from), + tool_calls: self + .response + .tool_calls + .iter() + .map(LlmToolCall::from) + .collect(), + } + } + + pub(crate) fn thinking_normalization_metadata(&self) -> Option<(usize, usize, &str)> { + (self.response.thinking_wrapper_valid + && self.response.thinking_wrapper_balanced + && self.response.thinking_normalization_count > 0) + .then(|| { + ( + self.response.thinking_normalization_count as usize, + self.response.thinking_source_text_chars, + self.response + .thinking_source_text_sha256 + .as_deref() + .expect("validated thinking normalization fingerprint"), + ) + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeToolPlanHandoffLedger { + pub(super) schema_version: String, + pub(super) agent_id: String, + pub(super) run_id: String, + pub(crate) entries: Vec, +} + +impl AgentRuntimeToolPlanHandoffLedger { + pub(crate) fn agent_id(&self) -> &str { + &self.agent_id + } + + pub(crate) fn run_id(&self) -> &str { + &self.run_id + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AgentRuntimeToolPlanHandoffLookup { + Missing, + Exact(AgentRuntimeToolPlanHandoffEntry), + IdentityConflict(AgentRuntimeToolPlanHandoffEntry), +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_common.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_common.rs new file mode 100644 index 000000000..974f15b57 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_common.rs @@ -0,0 +1,101 @@ +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; + +#[cfg(test)] +use super::model::TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY; +use super::model::{ + AgentRuntimeToolPlanHandoffLedger, AgentRuntimeToolPlanHandoffResponse, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, TOOL_PLAN_HANDOFF_TEMP_NONCE, +}; +#[cfg(unix)] +use super::storage_unix::write_ledger_at_unix; +#[cfg(windows)] +use super::storage_windows::write_ledger_at_windows; + +pub(super) fn serialize_ledger_for_storage( + ledger: &AgentRuntimeToolPlanHandoffLedger, +) -> Result, String> { + let mut bytes = serde_json::to_vec_pretty(ledger) + .map_err(|error| format!("序列化 tool-plan 成功响应交接账本失败:{error}"))?; + bytes.push(b'\n'); + if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + Ok(bytes) +} + +pub(super) fn write_ledger_at( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, +) -> Result<(), String> { + let bytes = serialize_ledger_for_storage(ledger)?; + #[cfg(unix)] + { + return write_ledger_at_unix(root, ledger, &bytes); + } + #[cfg(windows)] + { + return write_ledger_at_windows(root, ledger, &bytes); + } + #[cfg(not(any(unix, windows)))] + { + let _ = (root, bytes); + Err("当前平台不支持安全 tool-plan 成功响应交接写入".to_string()) + } +} + +pub(super) fn is_handoff_path_key(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +pub(super) fn response_fingerprint( + response: &AgentRuntimeToolPlanHandoffResponse, +) -> Result { + let bytes = serde_json::to_vec(response) + .map_err(|error| format!("序列化 tool-plan 成功响应指纹失败:{error}"))?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +pub(super) fn validate_path_identity(agent_id: &str, run_id: &str) -> Result<(), String> { + if agent_id.trim().is_empty() || run_id.trim().is_empty() { + return Err("tool-plan 成功响应交接路径的 Agent/run 身份不能为空".to_string()); + } + Ok(()) +} + +#[cfg(test)] +pub(super) fn tool_plan_handoff_relative_path(agent_id: &str, run_id: &str) -> String { + format!( + "{TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY}/{}/{}.json", + path_key(agent_id), + path_key(run_id) + ) +} + +#[cfg(any(test, not(unix)))] +#[cfg(test)] +pub(super) fn tool_plan_handoff_path(root: &Path, agent_id: &str, run_id: &str) -> PathBuf { + root.join(tool_plan_handoff_relative_path(agent_id, run_id)) +} + +pub(super) fn path_key(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +pub(super) fn next_tool_plan_temp_nonce() -> u128 { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + timestamp.saturating_add(u128::from( + TOOL_PLAN_HANDOFF_TEMP_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + )) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_unix.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_unix.rs new file mode 100644 index 000000000..4fcad5920 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_unix.rs @@ -0,0 +1,1219 @@ +use std::ffi::{CStr, CString}; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::Path; + +use super::content_validation::validate_ledger; +use super::discovery::{ + classify_handoff_file_name, select_primary_and_previous, DiscoveredToolPlanHandoffFileName, +}; +use super::model::{AgentRuntimeToolPlanHandoffLedger, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES}; +use super::storage_common::{next_tool_plan_temp_nonce, path_key}; + +#[cfg(unix)] +fn unix_tool_plan_component(value: &str, label: &str) -> Result { + if value.is_empty() || value == "." || value == ".." || value.contains('/') { + return Err(format!("{label} 名称无效")); + } + CString::new(value.as_bytes()).map_err(|_| format!("{label} 名称包含 NUL")) +} + +#[cfg(unix)] +fn validate_unix_tool_plan_directory_handle(file: &File, label: &str) -> Result<(), String> { + let metadata = file + .metadata() + .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_dir() || metadata.uid() != effective_user_id { + return Err(format!("{label} 必须是当前用户持有的普通目录")); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_unix_tool_plan_file_handle(file: &File, label: &str) -> Result<(), String> { + let metadata = file + .metadata() + .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() + || metadata.uid() != effective_user_id + || metadata.nlink() != 1 + || metadata.permissions().mode() & 0o777 != 0o600 + { + return Err(format!("{label} 必须是当前用户持有的 0600 单链接普通文件")); + } + Ok(()) +} + +#[cfg(unix)] +pub(super) fn verify_unix_tool_plan_root(root: &Path, opened: &File) -> Result<(), String> { + let path_metadata = fs::symlink_metadata(root) + .map_err(|error| format!("复核 tool-plan 项目根目录失败:{error}"))?; + let opened_metadata = opened + .metadata() + .map_err(|error| format!("复核 tool-plan 项目根目录句柄失败:{error}"))?; + if path_metadata.file_type().is_symlink() + || !path_metadata.is_dir() + || path_metadata.dev() != opened_metadata.dev() + || path_metadata.ino() != opened_metadata.ino() + { + return Err("tool-plan 项目根目录在安全扫描期间发生替换".to_string()); + } + Ok(()) +} + +#[cfg(unix)] +pub(super) fn verify_unix_tool_plan_entry( + parent: &File, + name: &str, + opened: &File, + directory: bool, + label: &str, +) -> Result<(), String> { + let name = unix_tool_plan_component(name, label)?; + // SAFETY: stat is plain data and fstatat initializes it on success. + let mut stat = unsafe { std::mem::zeroed::() }; + // SAFETY: parent and name remain valid for the duration of fstatat. + if unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(format!( + "复核 {label} 目录项失败:{}", + std::io::Error::last_os_error() + )); + } + let opened_metadata = opened + .metadata() + .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; + let expected_type = if directory { + libc::S_IFDIR + } else { + libc::S_IFREG + }; + if stat.st_dev != opened_metadata.dev() + || stat.st_ino != opened_metadata.ino() + || stat.st_mode & libc::S_IFMT != expected_type + { + return Err(format!("{label} 在安全扫描期间发生替换")); + } + Ok(()) +} + +#[cfg(unix)] +pub(super) fn lock_unix_tool_plan_directory(directory: &File, label: &str) -> Result<(), String> { + // SAFETY: flock operates on the live directory fd and is released when File is dropped. + if unsafe { libc::flock(directory.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(format!( + "锁定 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(unix)] +pub(super) fn open_unix_tool_plan_root(root: &Path) -> Result { + let root_name = CString::new(root.as_os_str().as_bytes()) + .map_err(|_| "tool-plan 项目根目录包含 NUL".to_string())?; + // SAFETY: root_name is NUL terminated and a successful fd is transferred to File once. + let fd = unsafe { + libc::open( + root_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开 tool-plan 项目根目录失败:{}", + std::io::Error::last_os_error() + )); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_directory_handle(&file, "tool-plan 项目根目录")?; + verify_unix_tool_plan_root(root, &file)?; + Ok(file) +} + +#[cfg(unix)] +pub(super) fn open_unix_tool_plan_directory_at( + parent: &File, + name: &str, + label: &str, +) -> Result, String> { + let name_c = unix_tool_plan_component(name, label)?; + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; + // SAFETY: parent fd and component remain valid during openat. + let fd = unsafe { libc::openat(parent.as_raw_fd(), name_c.as_ptr(), flags) }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + return Err(format!("安全打开 {label} 失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_directory_handle(&file, label)?; + verify_unix_tool_plan_entry(parent, name, &file, true, label)?; + Ok(Some(file)) +} + +#[cfg(unix)] +fn open_or_create_unix_tool_plan_directory_at( + parent: &File, + name: &str, + label: &str, +) -> Result { + if let Some(directory) = open_unix_tool_plan_directory_at(parent, name, label)? { + return Ok(directory); + } + let name_c = unix_tool_plan_component(name, label)?; + // SAFETY: parent is a stable directory fd and name is a validated relative component. + if unsafe { libc::mkdirat(parent.as_raw_fd(), name_c.as_ptr(), 0o700) } != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EEXIST) { + return Err(format!("创建 {label} 失败:{error}")); + } + } else { + parent + .sync_all() + .map_err(|error| format!("同步 {label} 父目录失败:{error}"))?; + } + open_unix_tool_plan_directory_at(parent, name, label)? + .ok_or_else(|| format!("创建后重新打开 {label} 失败")) +} + +#[cfg(unix)] +fn try_open_unix_tool_plan_file_at( + parent: &File, + name: &str, + label: &str, +) -> Result, String> { + let name_c = unix_tool_plan_component(name, label)?; + // SAFETY: parent fd and component remain valid during openat. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + return Err(format!("安全打开 {label} 失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_file_handle(&file, label)?; + verify_unix_tool_plan_entry(parent, name, &file, false, label)?; + Ok(Some(file)) +} + +#[cfg(unix)] +fn open_unix_tool_plan_file_for_removal_at( + parent: &File, + name: &str, + label: &str, +) -> Result, String> { + let name_c = unix_tool_plan_component(name, label)?; + // SAFETY: parent fd and component remain valid during openat. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name_c.as_ptr(), + libc::O_RDWR | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + return Err(format!("安全打开待隔离 {label} 失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_file_handle(&file, label)?; + verify_unix_tool_plan_entry(parent, name, &file, false, label)?; + Ok(Some(file)) +} + +#[cfg(unix)] +struct UnixToolPlanDirectoryStream(*mut libc::DIR); + +#[cfg(unix)] +impl Drop for UnixToolPlanDirectoryStream { + fn drop(&mut self) { + // SAFETY: this guard owns the DIR pointer returned by fdopendir. + unsafe { + libc::closedir(self.0); + } + } +} + +#[cfg(unix)] +pub(super) fn read_unix_tool_plan_directory_names( + directory: &File, + label: &str, +) -> Result, String> { + // SAFETY: fcntl duplicates the live directory fd and returns independent ownership. + let duplicated = unsafe { libc::fcntl(directory.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + if duplicated < 0 { + return Err(format!( + "复制 {label} 目录句柄失败:{}", + std::io::Error::last_os_error() + )); + } + // SAFETY: duplicated is an owned directory fd; fdopendir takes ownership on success. + let stream = unsafe { libc::fdopendir(duplicated) }; + if stream.is_null() { + let error = std::io::Error::last_os_error(); + // SAFETY: fdopendir failed, so duplicated remains owned here. + unsafe { + libc::close(duplicated); + } + return Err(format!("读取 {label} 目录失败:{error}")); + } + let stream = UnixToolPlanDirectoryStream(stream); + let mut names = Vec::new(); + loop { + // SAFETY: stream owns a valid DIR pointer for the duration of this loop. + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + break; + } + // SAFETY: d_name is NUL terminated for a successful readdir entry. + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + let name = std::str::from_utf8(name.to_bytes()) + .map_err(|_| format!("{label} 目录项名称必须是 UTF-8"))?; + if matches!(name, "." | "..") { + continue; + } + names.push(name.to_string()); + } + Ok(names) +} + +#[cfg(unix)] +pub(super) fn read_unix_discovered_ledger_file( + root: &Path, + parent: &File, + file_name: &str, + agent_key: &str, + run_key: &str, +) -> Result { + try_read_unix_discovered_ledger_file(root, parent, file_name, agent_key, run_key)? + .ok_or_else(|| "tool-plan 成功响应交接账本文件在安全扫描期间消失".to_string()) +} + +#[cfg(unix)] +fn try_read_unix_discovered_ledger_file( + root: &Path, + parent: &File, + file_name: &str, + agent_key: &str, + run_key: &str, +) -> Result, String> { + let Some(mut file) = + try_open_unix_tool_plan_file_at(parent, file_name, "tool-plan 成功响应交接账本文件")? + else { + return Ok(None); + }; + let metadata = file + .metadata() + .map_err(|error| format!("读取 tool-plan 成功响应交接账本元数据失败:{error}"))?; + if metadata.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES as u64 { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take((TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取 tool-plan 成功响应交接账本失败:{error}"))?; + if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + verify_unix_tool_plan_entry( + parent, + file_name, + &file, + false, + "tool-plan 成功响应交接账本文件", + )?; + let ledger = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 tool-plan 成功响应交接账本失败:{error}"))?; + validate_ledger(root, &ledger)?; + if path_key(&ledger.agent_id) != agent_key || path_key(&ledger.run_id) != run_key { + return Err("tool-plan 成功响应交接 hash 路径与 Agent/run 身份冲突".to_string()); + } + Ok(Some(ledger)) +} + +#[cfg(unix)] +pub(super) struct UnixToolPlanAgentStorage { + project_directory: File, + agent_runtime_directory: File, + runtime_directory: File, + handoff_directory: File, + pub(super) agent_directory: File, + agent_key: String, +} + +#[cfg(unix)] +impl UnixToolPlanAgentStorage { + fn verify(&self, root: &Path) -> Result<(), String> { + verify_unix_tool_plan_entry( + &self.handoff_directory, + &self.agent_key, + &self.agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + verify_unix_tool_plan_entry( + &self.runtime_directory, + "tool-plan-handoffs", + &self.handoff_directory, + true, + "tool-plan 成功响应交接根目录", + )?; + verify_unix_tool_plan_entry( + &self.agent_runtime_directory, + "runtime", + &self.runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_unix_tool_plan_entry( + &self.project_directory, + ".agent", + &self.agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_unix_tool_plan_root(root, &self.project_directory) + } +} + +#[cfg(unix)] +pub(super) fn open_unix_tool_plan_agent_storage( + root: &Path, + agent_id: &str, + create: bool, +) -> Result, String> { + let project_directory = open_unix_tool_plan_root(root)?; + let agent_runtime_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &project_directory, + ".agent", + "项目 .agent 目录", + )? + } else { + let Some(directory) = + open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? + else { + return Ok(None); + }; + directory + }; + let runtime_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + } else { + let Some(directory) = open_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + else { + return Ok(None); + }; + directory + }; + let handoff_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + } else { + let Some(directory) = open_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + else { + return Ok(None); + }; + directory + }; + lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; + let agent_key = path_key(agent_id); + let agent_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + } else { + let Some(directory) = open_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + else { + return Ok(None); + }; + directory + }; + let storage = UnixToolPlanAgentStorage { + project_directory, + agent_runtime_directory, + runtime_directory, + handoff_directory, + agent_directory, + agent_key, + }; + storage.verify(root)?; + Ok(Some(storage)) +} + +#[cfg(unix)] +pub(super) fn read_for_run_at_unix( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + read_for_run_at_unix_with_agent_open_hook(root, agent_id, run_id, |_| {}) +} + +#[cfg(unix)] +pub(super) fn read_for_run_at_unix_with_agent_open_hook( + root: &Path, + agent_id: &str, + run_id: &str, + after_agent_open: F, +) -> Result, String> +where + F: FnOnce(&str), +{ + let Some(storage) = open_unix_tool_plan_agent_storage(root, agent_id, false)? else { + return Ok(None); + }; + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + )?; + after_agent_open(&storage.agent_key); + let run_key = path_key(run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let primary = try_read_unix_discovered_ledger_file( + root, + &storage.agent_directory, + &primary_name, + &storage.agent_key, + &run_key, + )?; + let previous = try_read_unix_discovered_ledger_file( + root, + &storage.agent_directory, + &previous_name, + &storage.agent_key, + &run_key, + )?; + storage.verify(root)?; + let selected = select_primary_and_previous(&storage.agent_key, &run_key, primary, previous)?; + if selected + .as_ref() + .is_some_and(|ledger| ledger.agent_id != agent_id || ledger.run_id != run_id) + { + return Err("tool-plan 成功响应交接账本与路径 Agent/run 身份冲突".to_string()); + } + Ok(selected) +} + +#[cfg(unix)] +fn create_unix_tool_plan_temp_file_at( + parent: &File, + run_key: &str, +) -> Result<(String, File), String> { + for _ in 0..32 { + let file_name = format!( + ".{run_key}.json.tmp.{}.{}", + std::process::id(), + next_tool_plan_temp_nonce() + ); + let name = unix_tool_plan_component(&file_name, "tool-plan 成功响应交接临时文件")?; + // SAFETY: parent is stable, name is relative, and a successful fd is transferred once. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o600, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EEXIST) { + continue; + } + return Err(format!("创建 tool-plan 成功响应交接临时文件失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_file_handle(&file, "tool-plan 成功响应交接临时文件")?; + // SAFETY: flock operates on the live temp fd and the lock follows the open file. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err(format!( + "锁定 tool-plan 成功响应交接临时文件失败:{}", + std::io::Error::last_os_error() + )); + } + verify_unix_tool_plan_entry( + parent, + &file_name, + &file, + false, + "tool-plan 成功响应交接临时文件", + )?; + return Ok((file_name, file)); + } + Err("创建 tool-plan 成功响应交接临时文件失败:名称冲突".to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn exchange_unix_tool_plan_entries_at( + parent: &File, + left_name: &str, + right_name: &str, + label: &str, +) -> Result<(), String> { + let left = unix_tool_plan_component(left_name, label)?; + let right = unix_tool_plan_component(right_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameat2( + parent.as_raw_fd(), + left.as_ptr(), + parent.as_raw_fd(), + right.as_ptr(), + libc::RENAME_EXCHANGE, + ) + } != 0 + { + return Err(format!( + "原子交换 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(target_vendor = "apple")] +fn exchange_unix_tool_plan_entries_at( + parent: &File, + left_name: &str, + right_name: &str, + label: &str, +) -> Result<(), String> { + let left = unix_tool_plan_component(left_name, label)?; + let right = unix_tool_plan_component(right_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameatx_np( + parent.as_raw_fd(), + left.as_ptr(), + parent.as_raw_fd(), + right.as_ptr(), + libc::RENAME_SWAP, + ) + } != 0 + { + return Err(format!( + "原子交换 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(all( + unix, + not(any(target_os = "linux", target_os = "android")), + not(target_vendor = "apple") +))] +fn exchange_unix_tool_plan_entries_at( + _parent: &File, + _left_name: &str, + _right_name: &str, + _label: &str, +) -> Result<(), String> { + Err("当前 Unix 平台不支持安全原子交换 tool-plan 账本".to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn rename_unix_tool_plan_entry_noreplace_at( + parent: &File, + source_name: &str, + target_name: &str, + label: &str, +) -> Result<(), String> { + let source = unix_tool_plan_component(source_name, label)?; + let target = unix_tool_plan_component(target_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameat2( + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + } != 0 + { + return Err(format!( + "隔离 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(target_vendor = "apple")] +fn rename_unix_tool_plan_entry_noreplace_at( + parent: &File, + source_name: &str, + target_name: &str, + label: &str, +) -> Result<(), String> { + let source = unix_tool_plan_component(source_name, label)?; + let target = unix_tool_plan_component(target_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameatx_np( + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + target.as_ptr(), + libc::RENAME_EXCL, + ) + } != 0 + { + return Err(format!( + "隔离 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(all( + unix, + not(any(target_os = "linux", target_os = "android")), + not(target_vendor = "apple") +))] +fn rename_unix_tool_plan_entry_noreplace_at( + _parent: &File, + _source_name: &str, + _target_name: &str, + _label: &str, +) -> Result<(), String> { + Err("当前 Unix 平台不支持安全隔离 tool-plan 文件".to_string()) +} + +#[cfg(unix)] +fn rename_unix_tool_plan_entry_at( + parent: &File, + source_name: &str, + target_name: &str, + label: &str, +) -> Result<(), String> { + let source = unix_tool_plan_component(source_name, label)?; + let target = unix_tool_plan_component(target_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameat( + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + target.as_ptr(), + ) + } != 0 + { + return Err(format!( + "重命名 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(unix)] +pub(super) fn write_ledger_at_unix( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], +) -> Result<(), String> { + write_ledger_at_unix_with_hooks(root, ledger, bytes, |_| {}, |_| {}) +} + +#[cfg(all(unix, test))] +pub(super) fn write_ledger_at_unix_with_agent_open_hook( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], + after_agent_open: F, +) -> Result<(), String> +where + F: FnOnce(&str), +{ + write_ledger_at_unix_with_hooks(root, ledger, bytes, after_agent_open, |_| {}) +} + +#[cfg(unix)] +pub(super) fn write_ledger_at_unix_with_hooks( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], + after_agent_open: F, + before_install: G, +) -> Result<(), String> +where + F: FnOnce(&str), + G: FnOnce(&str), +{ + let storage = open_unix_tool_plan_agent_storage(root, &ledger.agent_id, true)? + .ok_or_else(|| "创建 tool-plan 成功响应交接存储目录失败".to_string())?; + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + )?; + after_agent_open(&storage.agent_key); + let run_key = path_key(&ledger.run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let (temporary_name, mut temporary_file) = + create_unix_tool_plan_temp_file_at(&storage.agent_directory, &run_key)?; + if let Err(error) = temporary_file + .write_all(bytes) + .and_then(|_| temporary_file.sync_data()) + { + let _ = remove_unix_tool_plan_file_at( + &storage.agent_directory, + &temporary_name, + "tool-plan 成功响应交接临时文件", + ); + return Err(format!("写入 tool-plan 成功响应交接临时文件失败:{error}")); + } + verify_unix_tool_plan_entry( + &storage.agent_directory, + &temporary_name, + &temporary_file, + false, + "tool-plan 成功响应交接临时文件", + )?; + let previous_primary = try_open_unix_tool_plan_file_at( + &storage.agent_directory, + &primary_name, + "tool-plan 成功响应交接原账本", + )?; + remove_unix_tool_plan_file_at( + &storage.agent_directory, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + before_install(&temporary_name); + if let Some(previous_primary) = previous_primary { + exchange_unix_tool_plan_entries_at( + &storage.agent_directory, + &temporary_name, + &primary_name, + "tool-plan 成功响应交接账本", + )?; + let installed = verify_unix_tool_plan_entry( + &storage.agent_directory, + &primary_name, + &temporary_file, + false, + "tool-plan 成功响应交接新账本", + ) + .and_then(|_| { + verify_unix_tool_plan_entry( + &storage.agent_directory, + &temporary_name, + &previous_primary, + false, + "tool-plan 成功响应交接原账本", + ) + }); + if let Err(install_error) = installed { + let rollback = exchange_unix_tool_plan_entries_at( + &storage.agent_directory, + &temporary_name, + &primary_name, + "tool-plan 成功响应交接账本回滚", + ) + .and_then(|_| { + verify_unix_tool_plan_entry( + &storage.agent_directory, + &primary_name, + &previous_primary, + false, + "tool-plan 成功响应交接原账本回滚", + ) + }); + return Err(match rollback { + Ok(()) => format!("安装 tool-plan 成功响应交接账本身份冲突:{install_error}"), + Err(rollback_error) => format!( + "安装 tool-plan 成功响应交接账本身份冲突且回滚失败:{install_error}; {rollback_error}" + ), + }); + } + rename_unix_tool_plan_entry_at( + &storage.agent_directory, + &temporary_name, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + verify_unix_tool_plan_entry( + &storage.agent_directory, + &previous_name, + &previous_primary, + false, + "tool-plan 成功响应交接恢复副本", + )?; + } else { + verify_unix_tool_plan_entry( + &storage.agent_directory, + &temporary_name, + &temporary_file, + false, + "tool-plan 成功响应交接临时文件", + )?; + rename_unix_tool_plan_entry_at( + &storage.agent_directory, + &temporary_name, + &primary_name, + "tool-plan 成功响应交接账本", + )?; + verify_unix_tool_plan_entry( + &storage.agent_directory, + &primary_name, + &temporary_file, + false, + "tool-plan 成功响应交接新账本", + )?; + } + storage + .agent_directory + .sync_all() + .map_err(|error| format!("同步 tool-plan 成功响应交接目录失败:{error}"))?; + storage.verify(root)?; + drop(temporary_file); + Ok(()) +} + +#[cfg(unix)] +fn try_lock_unix_tool_plan_temp(file: &File) -> Result { + // SAFETY: flock observes only the live temp file descriptor. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + return Ok(false); + } + Err(format!( + "确认 tool-plan 成功响应交接临时文件锁失败:{error}" + )) +} + +#[cfg(unix)] +pub(super) fn remove_stale_unix_handoff_temp_file_at( + parent: &File, + file_name: &str, +) -> Result<(), String> { + remove_stale_unix_handoff_temp_file_at_with_hook(parent, file_name, |_| {}) +} + +#[cfg(unix)] +pub(super) fn remove_stale_unix_handoff_temp_file_at_with_hook( + parent: &File, + file_name: &str, + after_lock: F, +) -> Result<(), String> +where + F: FnOnce(&str), +{ + let file = open_unix_tool_plan_file_for_removal_at( + parent, + file_name, + "tool-plan 成功响应交接原子临时文件", + )? + .ok_or_else(|| "tool-plan 成功响应交接原子临时文件在清理前消失".to_string())?; + if !try_lock_unix_tool_plan_temp(&file)? { + return Err("tool-plan 成功响应交接原子临时文件仍由活跃写入句柄持有".to_string()); + } + verify_unix_tool_plan_entry( + parent, + file_name, + &file, + false, + "tool-plan 成功响应交接原子临时文件", + )?; + after_lock(file_name); + quarantine_and_remove_unix_tool_plan_file_at( + parent, + file_name, + &file, + "tool-plan 成功响应交接原子临时文件", + )?; + parent + .sync_all() + .map_err(|error| format!("同步 tool-plan 成功响应交接临时文件目录失败:{error}"))?; + Ok(()) +} + +#[cfg(unix)] +pub(super) fn remove_at_unix_with_agent_open_hook( + root: &Path, + agent_id: &str, + run_id: &str, + mut after_agent_open: F, +) -> Result<(), String> +where + F: FnMut(&str), +{ + let project_directory = open_unix_tool_plan_root(root)?; + let Some(agent_runtime_directory) = + open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? + else { + return Ok(()); + }; + let Some(runtime_directory) = open_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + else { + return Ok(()); + }; + let Some(handoff_directory) = open_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + else { + return Ok(()); + }; + lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; + let agent_key = path_key(agent_id); + let Some(agent_directory) = open_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + else { + return Ok(()); + }; + lock_unix_tool_plan_directory(&agent_directory, "tool-plan 成功响应交接 Agent 目录")?; + after_agent_open(&agent_key); + let run_key = path_key(run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let removed_previous = remove_unix_tool_plan_file_at( + &agent_directory, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + let removed_primary = remove_unix_tool_plan_file_at( + &agent_directory, + &primary_name, + "tool-plan 成功响应交接账本", + )?; + if removed_previous || removed_primary { + agent_directory + .sync_all() + .map_err(|error| format!("同步 tool-plan 成功响应交接删除目录失败:{error}"))?; + } + verify_unix_tool_plan_entry( + &handoff_directory, + &agent_key, + &agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + verify_unix_tool_plan_entry( + &runtime_directory, + "tool-plan-handoffs", + &handoff_directory, + true, + "tool-plan 成功响应交接根目录", + )?; + verify_unix_tool_plan_entry( + &agent_runtime_directory, + "runtime", + &runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_unix_tool_plan_entry( + &project_directory, + ".agent", + &agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_unix_tool_plan_root(root, &project_directory) +} + +#[cfg(unix)] +fn remove_unix_tool_plan_file_at( + parent: &File, + file_name: &str, + label: &str, +) -> Result { + remove_unix_tool_plan_file_at_with_hook(parent, file_name, label, |_| {}) +} + +#[cfg(unix)] +pub(super) fn remove_unix_tool_plan_file_at_with_hook( + parent: &File, + file_name: &str, + label: &str, + after_open: F, +) -> Result +where + F: FnOnce(&str), +{ + let Some(file) = open_unix_tool_plan_file_for_removal_at(parent, file_name, label)? else { + return Ok(false); + }; + verify_unix_tool_plan_entry(parent, file_name, &file, false, label)?; + after_open(file_name); + quarantine_and_remove_unix_tool_plan_file_at(parent, file_name, &file, label)?; + Ok(true) +} + +#[cfg(unix)] +fn quarantine_and_remove_unix_tool_plan_file_at( + parent: &File, + file_name: &str, + file: &File, + label: &str, +) -> Result<(), String> { + let run_key = match classify_handoff_file_name(file_name) { + Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) + | Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) + | Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => run_key, + None => return Err(format!("{label} 文件名无法生成安全隔离名称")), + }; + let quarantine_name = format!( + ".{run_key}.json.tmp.{}.{}", + std::process::id(), + next_tool_plan_temp_nonce() + ); + rename_unix_tool_plan_entry_noreplace_at(parent, file_name, &quarantine_name, label)?; + let quarantined = match open_unix_tool_plan_file_for_removal_at(parent, &quarantine_name, label) + { + Ok(Some(quarantined)) => quarantined, + Ok(None) => { + return Err(format!("{label} 隔离后消失,保留现场等待 reconciliation")); + } + Err(error) => { + let rollback = rename_unix_tool_plan_entry_noreplace_at( + parent, + &quarantine_name, + file_name, + &format!("{label} 隔离回滚"), + ); + return Err(match rollback { + Ok(()) => format!("{label} 隔离对象无效且已回滚:{error}"), + Err(rollback_error) => { + format!("{label} 隔离对象无效且回滚失败:{error}; {rollback_error}") + } + }); + } + }; + let matches_opened = { + let opened_metadata = file + .metadata() + .map_err(|error| format!("复核 {label} 原句柄失败:{error}"))?; + let quarantined_metadata = quarantined + .metadata() + .map_err(|error| format!("复核 {label} 隔离句柄失败:{error}"))?; + opened_metadata.dev() == quarantined_metadata.dev() + && opened_metadata.ino() == quarantined_metadata.ino() + }; + if !matches_opened { + let rollback = rename_unix_tool_plan_entry_noreplace_at( + parent, + &quarantine_name, + file_name, + &format!("{label} 名称换绑回滚"), + ) + .and_then(|_| { + verify_unix_tool_plan_entry( + parent, + file_name, + &quarantined, + false, + &format!("{label} 名称换绑回滚"), + ) + }); + return Err(match rollback { + Ok(()) => format!("{label} 删除前发生名称换绑,替换对象已回滚"), + Err(rollback_error) => { + format!("{label} 删除前发生名称换绑且回滚失败,已保留隔离对象:{rollback_error}") + } + }); + } + file.set_len(0) + .and_then(|_| file.sync_data()) + .map_err(|error| format!("清空并同步已隔离 {label} 失败:{error}"))?; + verify_unix_tool_plan_entry(parent, &quarantine_name, file, false, label)?; + let quarantine = unix_tool_plan_component(&quarantine_name, label)?; + // SAFETY: parent is stable and quarantine is the freshly verified relative component. + if unsafe { libc::unlinkat(parent.as_raw_fd(), quarantine.as_ptr(), 0) } != 0 { + return Err(format!( + "删除已隔离 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + verify_unix_tool_plan_file_unlinked(file, label) +} + +#[cfg(unix)] +fn verify_unix_tool_plan_file_unlinked(file: &File, label: &str) -> Result<(), String> { + let metadata = file + .metadata() + .map_err(|error| format!("复核已删除 {label} 句柄失败:{error}"))?; + if metadata.nlink() != 0 { + return Err(format!("{label} 删除期间发生名称换绑")); + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs new file mode 100644 index 000000000..8b695b239 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/storage_windows.rs @@ -0,0 +1,1040 @@ +use std::collections::BTreeMap; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use super::content_validation::validate_ledger; +use super::discovery::{ + classify_handoff_file_name, discovered_handoff_ledgers_mut, select_primary_and_previous, + DiscoveredToolPlanHandoffFileName, DiscoveredToolPlanHandoffLedgers, +}; +use super::model::{ + AgentRuntimeToolPlanHandoffLedger, TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS, + TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, +}; +use super::storage_common::{is_handoff_path_key, next_tool_plan_temp_nonce, path_key}; + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WindowsToolPlanFileIdentity { + volume_serial_number: u32, + file_index: u64, + number_of_links: u32, + file_attributes: u32, +} + +#[cfg(windows)] +#[derive(Clone, Copy)] +enum WindowsToolPlanOpenDisposition { + Existing, + OpenOrCreate, + CreateNew, +} + +#[cfg(windows)] +fn windows_tool_plan_file_identity(file: &File) -> Result { + use std::ffi::c_void; + use std::os::windows::io::AsRawHandle; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + #[repr(C)] + struct ByHandleFileInformation { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle( + file: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + } + + // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live handle and information is a valid output pointer. + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 { + return Err(format!( + "读取 Windows tool-plan 文件句柄身份失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(WindowsToolPlanFileIdentity { + volume_serial_number: information.volume_serial_number, + file_index: (u64::from(information.file_index_high) << 32) + | u64::from(information.file_index_low), + number_of_links: information.number_of_links, + file_attributes: information.file_attributes, + }) +} + +#[cfg(windows)] +fn validate_windows_tool_plan_directory_handle(file: &File, label: &str) -> Result<(), String> { + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + let metadata = file + .metadata() + .map_err(|error| format!("读取 {label} 句柄元数据失败:{error}"))?; + let identity = windows_tool_plan_file_identity(file)?; + if !metadata.is_dir() || identity.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 必须是普通目录且不能是 Windows junction/reparse point" + )); + } + Ok(()) +} + +#[cfg(windows)] +fn validate_windows_tool_plan_file_handle(file: &File, label: &str) -> Result<(), String> { + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + let metadata = file + .metadata() + .map_err(|error| format!("读取 {label} 句柄元数据失败:{error}"))?; + let identity = windows_tool_plan_file_identity(file)?; + if !metadata.is_file() + || identity.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || identity.number_of_links != 1 + { + return Err(format!("{label} 必须是无 reparse point 的单链接普通文件")); + } + Ok(()) +} + +#[cfg(windows)] +fn read_windows_tool_plan_directory_names( + directory: &File, + label: &str, + max_names: usize, +) -> Result, String> { + use std::ffi::c_void; + use std::mem::{offset_of, size_of}; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::{ + Foundation::ERROR_NO_MORE_FILES, + Storage::FileSystem::{ + FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, GetFileInformationByHandleEx, + FILE_ID_BOTH_DIR_INFO, + }, + }; + + const BUFFER_SIZE: usize = 64 * 1024; + const NAME_OFFSET: usize = offset_of!(FILE_ID_BOTH_DIR_INFO, FileName); + const DOT: u16 = b'.' as u16; + + #[repr(align(8))] + struct DirectoryBuffer([u8; BUFFER_SIZE]); + + let mut buffer = Box::new(DirectoryBuffer([0; BUFFER_SIZE])); + let base = buffer.0.as_mut_ptr(); + let malformed = || format!("{label} 返回了无效的 Windows 目录枚举缓冲区"); + let mut restart = true; + let mut names = Vec::new(); + loop { + let information_class = if restart { + restart = false; + FileIdBothDirectoryRestartInfo + } else { + FileIdBothDirectoryInfo + }; + // SAFETY: directory is a live directory handle and buffer is aligned and writable. + let succeeded = unsafe { + GetFileInformationByHandleEx( + directory.as_raw_handle().cast(), + information_class, + base.cast::(), + BUFFER_SIZE as u32, + ) + }; + if succeeded == 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_NO_MORE_FILES as i32) { + break; + } + return Err(format!("按句柄读取 {label} 失败:{error}")); + } + + let mut offset = 0usize; + loop { + let remaining = BUFFER_SIZE.checked_sub(offset).ok_or_else(&malformed)?; + if remaining < size_of::() { + return Err(malformed()); + } + // SAFETY: offset is bounds-checked and every entry is required to be 8-byte aligned. + let information = unsafe { &*base.add(offset).cast::() }; + let name_bytes = information.FileNameLength as usize; + let used = NAME_OFFSET.checked_add(name_bytes).ok_or_else(&malformed)?; + let next = information.NextEntryOffset as usize; + if name_bytes == 0 || name_bytes % size_of::() != 0 || used > remaining { + return Err(malformed()); + } + if next != 0 && (next % 8 != 0 || next < used || next > remaining) { + return Err(malformed()); + } + // SAFETY: FileNameLength was checked against the remaining buffer and is UTF-16 bytes. + let wide_name = unsafe { + std::slice::from_raw_parts( + base.add(offset + NAME_OFFSET).cast::(), + name_bytes / size_of::(), + ) + }; + if !matches!(wide_name, [DOT] | [DOT, DOT]) { + if names.len() >= max_names { + return Err(format!("{label} 超过 {max_names} 个目录项上限")); + } + names.push( + String::from_utf16(wide_name) + .map_err(|_| format!("{label} 目录项名称必须是有效 UTF-16"))?, + ); + } + if next == 0 { + break; + } + offset = offset.checked_add(next).ok_or_else(&malformed)?; + } + } + Ok(names) +} + +#[cfg(windows)] +fn open_windows_tool_plan_root(root: &Path, writable: bool) -> Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let file = OpenOptions::new() + .read(true) + .write(writable) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(root) + .map_err(|error| format!("安全打开 Windows tool-plan 项目根目录失败:{error}"))?; + validate_windows_tool_plan_directory_handle(&file, "tool-plan 项目根目录")?; + Ok(file) +} + +#[cfg(windows)] +fn nt_open_windows_tool_plan_relative( + parent: &File, + name: &str, + directory: bool, + disposition: WindowsToolPlanOpenDisposition, + writable: bool, + exclusive: bool, + delete_access: bool, +) -> std::io::Result { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + + if name.is_empty() + || matches!(name, "." | "..") + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid relative component", + )); + } + + type Handle = *mut c_void; + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, + } + #[repr(C)] + struct ObjectAttributes { + length: u32, + root_directory: Handle, + object_name: *mut UnicodeString, + attributes: u32, + security_descriptor: *mut c_void, + security_quality_of_service: *mut c_void, + } + #[repr(C)] + struct IoStatusBlock { + status: isize, + information: usize, + } + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtCreateFile( + file_handle: *mut Handle, + desired_access: u32, + object_attributes: *mut ObjectAttributes, + io_status_block: *mut IoStatusBlock, + allocation_size: *mut i64, + file_attributes: u32, + share_access: u32, + create_disposition: u32, + create_options: u32, + ea_buffer: *mut c_void, + ea_length: u32, + ) -> i32; + fn RtlNtStatusToDosError(status: i32) -> u32; + } + + const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + const FILE_OPEN: u32 = 0x0000_0001; + const FILE_CREATE: u32 = 0x0000_0002; + const FILE_OPEN_IF: u32 = 0x0000_0003; + const FILE_DIRECTORY_FILE: u32 = 0x0000_0001; + const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; + const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; + const FILE_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080; + const FILE_LIST_DIRECTORY: u32 = 0x0000_0001; + const FILE_ADD_FILE: u32 = 0x0000_0002; + const FILE_ADD_SUBDIRECTORY: u32 = 0x0000_0004; + const FILE_TRAVERSE: u32 = 0x0000_0020; + const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080; + const READ_CONTROL: u32 = 0x0002_0000; + const DELETE: u32 = 0x0001_0000; + const SYNCHRONIZE: u32 = 0x0010_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + + let mut wide_name = std::ffi::OsStr::new(name).encode_wide().collect::>(); + let byte_length = wide_name + .len() + .checked_mul(2) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "relative name too long") + })?; + let mut unicode_name = UnicodeString { + length: byte_length, + maximum_length: byte_length, + buffer: wide_name.as_mut_ptr(), + }; + let mut attributes = ObjectAttributes { + length: std::mem::size_of::() as u32, + root_directory: parent.as_raw_handle().cast(), + object_name: &mut unicode_name, + attributes: OBJ_CASE_INSENSITIVE, + security_descriptor: std::ptr::null_mut(), + security_quality_of_service: std::ptr::null_mut(), + }; + let mut io_status = IoStatusBlock { + status: 0, + information: 0, + }; + let mut handle = std::ptr::null_mut(); + let mut desired_access = if directory { + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE + } else { + GENERIC_READ | READ_CONTROL | SYNCHRONIZE + }; + if writable { + desired_access |= if directory { + FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY + } else { + GENERIC_WRITE + }; + } + if delete_access { + desired_access |= DELETE; + } + let create_options = if directory { + FILE_DIRECTORY_FILE + } else { + FILE_NON_DIRECTORY_FILE + } | FILE_SYNCHRONOUS_IO_NONALERT + | FILE_OPEN_REPARSE_POINT; + let create_disposition = match disposition { + WindowsToolPlanOpenDisposition::Existing => FILE_OPEN, + WindowsToolPlanOpenDisposition::OpenOrCreate => FILE_OPEN_IF, + WindowsToolPlanOpenDisposition::CreateNew => FILE_CREATE, + }; + // SAFETY: all NT structures and buffers remain live for this call; handle is an output. + let status = unsafe { + NtCreateFile( + &mut handle, + desired_access, + &mut attributes, + &mut io_status, + std::ptr::null_mut(), + FILE_ATTRIBUTE_NORMAL, + if exclusive { + 0 + } else { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + }, + create_disposition, + create_options, + std::ptr::null_mut(), + 0, + ) + }; + if status < 0 || handle.is_null() { + // SAFETY: conversion accepts any NTSTATUS and returns a Win32 error code. + let code = unsafe { RtlNtStatusToDosError(status) }; + return Err(std::io::Error::from_raw_os_error(code as i32)); + } + // SAFETY: NtCreateFile returned an owned handle transferred exactly once to File. + Ok(unsafe { File::from_raw_handle(handle.cast()) }) +} + +#[cfg(windows)] +fn windows_tool_plan_error_is_not_found(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::NotFound + || matches!(error.raw_os_error(), Some(2) | Some(3)) +} + +#[cfg(windows)] +fn windows_tool_plan_error_is_sharing_violation(error: &std::io::Error) -> bool { + matches!(error.raw_os_error(), Some(32) | Some(33)) +} + +#[cfg(windows)] +fn open_windows_tool_plan_directory_at( + parent: &File, + name: &str, + label: &str, + create: bool, +) -> Result, String> { + let disposition = if create { + WindowsToolPlanOpenDisposition::OpenOrCreate + } else { + WindowsToolPlanOpenDisposition::Existing + }; + let file = match nt_open_windows_tool_plan_relative( + parent, + name, + true, + disposition, + create, + false, + false, + ) { + Ok(file) => file, + Err(error) if !create && windows_tool_plan_error_is_not_found(&error) => return Ok(None), + Err(error) => return Err(format!("安全相对打开 {label} 失败:{error}")), + }; + validate_windows_tool_plan_directory_handle(&file, label)?; + Ok(Some(file)) +} + +#[cfg(windows)] +fn try_open_windows_tool_plan_file_at( + parent: &File, + name: &str, + label: &str, + exclusive: bool, + delete_access: bool, +) -> Result, String> { + let file = match nt_open_windows_tool_plan_relative( + parent, + name, + false, + WindowsToolPlanOpenDisposition::Existing, + false, + exclusive, + delete_access, + ) { + Ok(file) => file, + Err(error) if windows_tool_plan_error_is_not_found(&error) => return Ok(None), + Err(error) if windows_tool_plan_error_is_sharing_violation(&error) => { + return Err(format!("{label} 仍由活跃写入句柄持有")); + } + Err(error) => return Err(format!("安全相对打开 {label} 失败:{error}")), + }; + validate_windows_tool_plan_file_handle(&file, label)?; + Ok(Some(file)) +} + +#[cfg(windows)] +fn verify_windows_tool_plan_entry( + parent: &File, + name: &str, + opened: &File, + directory: bool, + label: &str, +) -> Result<(), String> { + let current = nt_open_windows_tool_plan_relative( + parent, + name, + directory, + WindowsToolPlanOpenDisposition::Existing, + false, + false, + false, + ) + .map_err(|error| format!("复核 {label} 目录项失败:{error}"))?; + if directory { + validate_windows_tool_plan_directory_handle(¤t, label)?; + } else { + validate_windows_tool_plan_file_handle(¤t, label)?; + } + if windows_tool_plan_file_identity(¤t)? != windows_tool_plan_file_identity(opened)? { + return Err(format!("{label} 在安全操作期间发生替换")); + } + Ok(()) +} + +#[cfg(windows)] +struct WindowsToolPlanRootStorage { + root_path: PathBuf, + project_directory: File, + agent_runtime_directory: File, + runtime_directory: File, + handoff_directory: File, +} + +#[cfg(windows)] +impl WindowsToolPlanRootStorage { + fn verify(&self) -> Result<(), String> { + let current_root = open_windows_tool_plan_root(&self.root_path, false)?; + if windows_tool_plan_file_identity(¤t_root)? + != windows_tool_plan_file_identity(&self.project_directory)? + { + return Err("Windows tool-plan 项目根目录在安全操作期间发生替换".to_string()); + } + verify_windows_tool_plan_entry( + &self.project_directory, + ".agent", + &self.agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_windows_tool_plan_entry( + &self.agent_runtime_directory, + "runtime", + &self.runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_windows_tool_plan_entry( + &self.runtime_directory, + "tool-plan-handoffs", + &self.handoff_directory, + true, + "tool-plan 成功响应交接根目录", + ) + } +} + +#[cfg(windows)] +fn open_windows_tool_plan_root_storage( + root: &Path, + create: bool, +) -> Result, String> { + let project_directory = open_windows_tool_plan_root(root, create)?; + let Some(agent_runtime_directory) = open_windows_tool_plan_directory_at( + &project_directory, + ".agent", + "项目 .agent 目录", + create, + )? + else { + return Ok(None); + }; + let Some(runtime_directory) = open_windows_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + create, + )? + else { + return Ok(None); + }; + let Some(handoff_directory) = open_windows_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + create, + )? + else { + return Ok(None); + }; + let storage = WindowsToolPlanRootStorage { + root_path: root.to_path_buf(), + project_directory, + agent_runtime_directory, + runtime_directory, + handoff_directory, + }; + storage.verify()?; + Ok(Some(storage)) +} + +#[cfg(windows)] +pub(super) struct WindowsToolPlanAgentStorage { + root: WindowsToolPlanRootStorage, + pub(super) agent_directory: File, + agent_key: String, +} + +#[cfg(windows)] +impl WindowsToolPlanAgentStorage { + fn verify(&self) -> Result<(), String> { + self.root.verify()?; + verify_windows_tool_plan_entry( + &self.root.handoff_directory, + &self.agent_key, + &self.agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + ) + } +} + +#[cfg(windows)] +pub(super) fn open_windows_tool_plan_agent_storage( + root: &Path, + agent_id: &str, + create: bool, +) -> Result, String> { + let Some(root_storage) = open_windows_tool_plan_root_storage(root, create)? else { + return Ok(None); + }; + let agent_key = path_key(agent_id); + let Some(agent_directory) = open_windows_tool_plan_directory_at( + &root_storage.handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + create, + )? + else { + return Ok(None); + }; + let storage = WindowsToolPlanAgentStorage { + root: root_storage, + agent_directory, + agent_key, + }; + storage.verify()?; + Ok(Some(storage)) +} + +#[cfg(windows)] +fn try_read_windows_discovered_ledger_file( + root: &Path, + parent: &File, + file_name: &str, + agent_key: &str, + run_key: &str, +) -> Result, String> { + let Some(mut file) = try_open_windows_tool_plan_file_at( + parent, + file_name, + "tool-plan 成功响应交接账本文件", + false, + false, + )? + else { + return Ok(None); + }; + let metadata = file + .metadata() + .map_err(|error| format!("读取 Windows tool-plan 账本元数据失败:{error}"))?; + if metadata.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES as u64 { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take((TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取 Windows tool-plan 成功响应交接账本失败:{error}"))?; + if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + verify_windows_tool_plan_entry( + parent, + file_name, + &file, + false, + "tool-plan 成功响应交接账本文件", + )?; + let ledger = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 Windows tool-plan 成功响应交接账本失败:{error}"))?; + validate_ledger(root, &ledger)?; + if path_key(&ledger.agent_id) != agent_key || path_key(&ledger.run_id) != run_key { + return Err("tool-plan 成功响应交接 hash 路径与 Agent/run 身份冲突".to_string()); + } + Ok(Some(ledger)) +} + +#[cfg(windows)] +pub(super) fn read_for_run_at_windows( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + let Some(storage) = open_windows_tool_plan_agent_storage(root, agent_id, false)? else { + return Ok(None); + }; + let run_key = path_key(run_id); + let primary = try_read_windows_discovered_ledger_file( + root, + &storage.agent_directory, + &format!("{run_key}.json"), + &storage.agent_key, + &run_key, + )?; + let previous = try_read_windows_discovered_ledger_file( + root, + &storage.agent_directory, + &format!(".{run_key}.json.previous"), + &storage.agent_key, + &run_key, + )?; + storage.verify()?; + let selected = select_primary_and_previous(&storage.agent_key, &run_key, primary, previous)?; + if selected + .as_ref() + .is_some_and(|ledger| ledger.agent_id != agent_id || ledger.run_id != run_id) + { + return Err("tool-plan 成功响应交接账本与路径 Agent/run 身份冲突".to_string()); + } + Ok(selected) +} + +#[cfg(windows)] +fn set_windows_tool_plan_file_deleted(file: &File, label: &str) -> Result<(), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: file owns a DELETE-capable handle and disposition is a valid fixed-size buffer. + if unsafe { + SetFileInformationByHandle( + file.as_raw_handle().cast(), + FileDispositionInfo, + (&disposition as *const FILE_DISPOSITION_INFO).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(format!( + "按句柄删除 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(windows)] +fn rename_windows_tool_plan_file_at( + file: &File, + parent: &File, + new_name: &str, + replace: bool, + label: &str, +) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileRenameInfo, SetFileInformationByHandle, FILE_RENAME_INFO, + }; + + let wide_name = std::ffi::OsStr::new(new_name) + .encode_wide() + .collect::>(); + let name_bytes = wide_name + .len() + .checked_mul(2) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| format!("{label} 目标名称过长"))?; + let header_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName); + let total_bytes = header_bytes + .checked_add(name_bytes as usize) + .ok_or_else(|| format!("{label} 重命名缓冲区过大"))?; + let word_bytes = std::mem::size_of::(); + let mut buffer = vec![0usize; total_bytes.div_ceil(word_bytes)]; + let information = buffer.as_mut_ptr().cast::(); + // SAFETY: buffer is aligned and sized for the fixed header plus the complete UTF-16 name. + unsafe { + (*information).Anonymous.ReplaceIfExists = replace; + (*information).RootDirectory = parent.as_raw_handle().cast(); + (*information).FileNameLength = name_bytes; + std::ptr::copy_nonoverlapping( + wide_name.as_ptr(), + (*information).FileName.as_mut_ptr(), + wide_name.len(), + ); + } + // SAFETY: file owns a DELETE-capable handle and information spans total_bytes bytes. + if unsafe { + SetFileInformationByHandle( + file.as_raw_handle().cast(), + FileRenameInfo, + information.cast(), + total_bytes as u32, + ) + } == 0 + { + return Err(format!( + "按句柄安装 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(windows)] +fn remove_windows_tool_plan_file_at( + parent: &File, + file_name: &str, + label: &str, +) -> Result { + let Some(file) = try_open_windows_tool_plan_file_at(parent, file_name, label, true, true)? + else { + return Ok(false); + }; + set_windows_tool_plan_file_deleted(&file, label)?; + Ok(true) +} + +#[cfg(windows)] +pub(super) fn create_windows_tool_plan_temp_file_at( + parent: &File, + run_key: &str, +) -> Result<(String, File), String> { + for _ in 0..32 { + let file_name = format!( + ".{run_key}.json.tmp.{}.{}", + std::process::id(), + next_tool_plan_temp_nonce() + ); + match nt_open_windows_tool_plan_relative( + parent, + &file_name, + false, + WindowsToolPlanOpenDisposition::CreateNew, + true, + true, + true, + ) { + Ok(file) => { + validate_windows_tool_plan_file_handle(&file, "tool-plan 成功响应交接临时文件")?; + return Ok((file_name, file)); + } + Err(error) + if error.kind() == std::io::ErrorKind::AlreadyExists + || matches!(error.raw_os_error(), Some(80) | Some(183)) => + { + continue; + } + Err(error) => { + return Err(format!( + "创建 Windows tool-plan 成功响应交接临时文件失败:{error}" + )); + } + } + } + Err("创建 Windows tool-plan 成功响应交接临时文件失败:名称冲突".to_string()) +} + +#[cfg(windows)] +pub(super) fn write_ledger_at_windows( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], +) -> Result<(), String> { + let storage = open_windows_tool_plan_agent_storage(root, &ledger.agent_id, true)? + .ok_or_else(|| "创建 Windows tool-plan 成功响应交接存储目录失败".to_string())?; + let run_key = path_key(&ledger.run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let (_temporary_name, mut temporary_file) = + create_windows_tool_plan_temp_file_at(&storage.agent_directory, &run_key)?; + if let Err(error) = temporary_file + .write_all(bytes) + .and_then(|_| temporary_file.sync_data()) + { + let _ = + set_windows_tool_plan_file_deleted(&temporary_file, "tool-plan 成功响应交接临时文件"); + return Err(format!( + "写入 Windows tool-plan 成功响应交接临时文件失败:{error}" + )); + } + remove_windows_tool_plan_file_at( + &storage.agent_directory, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + if let Err(error) = rename_windows_tool_plan_file_at( + &temporary_file, + &storage.agent_directory, + &primary_name, + true, + "tool-plan 成功响应交接账本", + ) { + let _ = + set_windows_tool_plan_file_deleted(&temporary_file, "tool-plan 成功响应交接临时文件"); + return Err(error); + } + temporary_file + .sync_all() + .map_err(|error| format!("同步 Windows tool-plan 成功响应交接账本失败:{error}"))?; + storage.verify()?; + Ok(()) +} + +#[cfg(windows)] +pub(super) fn remove_at_windows(root: &Path, agent_id: &str, run_id: &str) -> Result<(), String> { + let Some(storage) = open_windows_tool_plan_agent_storage(root, agent_id, false)? else { + return Ok(()); + }; + let run_key = path_key(run_id); + remove_windows_tool_plan_file_at( + &storage.agent_directory, + &format!(".{run_key}.json.previous"), + "tool-plan 成功响应交接恢复副本", + )?; + remove_windows_tool_plan_file_at( + &storage.agent_directory, + &format!("{run_key}.json"), + "tool-plan 成功响应交接账本", + )?; + storage.verify() +} + +#[cfg(windows)] +pub(super) fn list_at_windows( + root: &Path, +) -> Result, String> { + let Some(root_storage) = open_windows_tool_plan_root_storage(root, false)? else { + return Ok(Vec::new()); + }; + let agent_names = read_windows_tool_plan_directory_names( + &root_storage.handoff_directory, + "Windows tool-plan Agent 根目录", + TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS, + )?; + let mut discovered = BTreeMap::<(String, String), DiscoveredToolPlanHandoffLedgers>::new(); + let mut discovered_file_count = 0usize; + for (agent_index, agent_key) in agent_names.into_iter().enumerate() { + if agent_index >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS} 个 Agent 上限" + )); + } + if !is_handoff_path_key(&agent_key) { + return Err(format!( + "tool-plan 成功响应交接 Agent 目录名不是规范 hash:{agent_key}" + )); + } + let agent_directory = open_windows_tool_plan_directory_at( + &root_storage.handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + false, + )? + .ok_or_else(|| "tool-plan 成功响应交接 Agent 目录在扫描期间消失".to_string())?; + let run_names = read_windows_tool_plan_directory_names( + &agent_directory, + "Windows tool-plan run 目录", + TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES, + )?; + for (run_index, file_name) in run_names.into_iter().enumerate() { + if run_index >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + || discovered_file_count >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES} 个文件上限" + )); + } + discovered_file_count += 1; + match classify_handoff_file_name(&file_name) { + Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) => { + let ledger = try_read_windows_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )? + .ok_or_else(|| "tool-plan 成功响应交接 primary 在扫描期间消失".to_string())?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.primary.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 primary 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) => { + let ledger = try_read_windows_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )? + .ok_or_else(|| ".previous 在 Windows tool-plan 扫描期间消失".to_string())?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.previous.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 .previous 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => { + if !is_handoff_path_key(run_key) { + return Err(format!( + "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" + )); + } + remove_windows_tool_plan_file_at( + &agent_directory, + &file_name, + "tool-plan 成功响应交接原子临时文件", + )?; + } + None => { + return Err(format!( + "tool-plan 成功响应交接目录包含未知文件:{file_name}" + )); + } + } + } + verify_windows_tool_plan_entry( + &root_storage.handoff_directory, + &agent_key, + &agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + } + root_storage.verify()?; + let mut ledgers = Vec::with_capacity(discovered.len()); + for ((agent_key, run_key), files) in discovered { + if let Some(ledger) = + select_primary_and_previous(&agent_key, &run_key, files.primary, files.previous)? + { + ledgers.push(ledger); + } + } + ledgers.sort_by(|left, right| { + left.agent_id + .cmp(&right.agent_id) + .then_with(|| left.run_id.cmp(&right.run_id)) + }); + Ok(ledgers) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs new file mode 100644 index 000000000..c2673cf20 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -0,0 +1,1697 @@ +use std::fs; +use std::io::Write; + +#[cfg(unix)] +use std::os::fd::AsRawFd; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use platform_llm::{LlmProvider, LlmRunResponse, LlmTokenUsage, LlmToolCall}; +use sha2::{Digest, Sha256}; +use tempfile::tempdir; + +use super::content_validation::validate_ledger; +#[cfg(unix)] +use super::discovery::list_at_unix_with_agent_open_hook; +use super::identity_order_validation::{ + parse_tool_plan_base_request_slot, request_slot_for_attempt, +}; +use super::ledger::response_for_persistence; +use super::model::{ + INVALID_THINKING_CLOSE_MARKER, INVALID_THINKING_OPEN_MARKER, + TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES, TOOL_PLAN_HANDOFF_LABEL, TOOL_PLAN_HANDOFF_MAX_ENTRIES, + TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY, + TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, +}; +use super::storage_common::{ + path_key, response_fingerprint, serialize_ledger_for_storage, tool_plan_handoff_path, + tool_plan_handoff_relative_path, +}; +#[cfg(unix)] +use super::storage_unix::{ + lock_unix_tool_plan_directory, open_unix_tool_plan_agent_storage, + read_for_run_at_unix_with_agent_open_hook, remove_at_unix_with_agent_open_hook, + remove_stale_unix_handoff_temp_file_at_with_hook, remove_unix_tool_plan_file_at_with_hook, + write_ledger_at_unix_with_agent_open_hook, write_ledger_at_unix_with_hooks, +}; +#[cfg(windows)] +use super::storage_windows::{ + create_windows_tool_plan_temp_file_at, open_windows_tool_plan_agent_storage, +}; +use super::*; +use crate::agent::{ + agent_runtime_json_sidecar_backup_path, write_agent_runtime_json_sidecar_with_max_bytes, +}; +use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity}; + +fn identity(slot: &str) -> AgentRuntimeProviderRetryIdentity { + identity_for(slot, "project-supervisor", "run-tool-plan-handoff") +} + +fn identity_for(slot: &str, agent_id: &str, run_id: &str) -> AgentRuntimeProviderRetryIdentity { + let (_, repair_attempt) = parse_tool_plan_base_request_slot(slot).expect("valid test slot"); + AgentRuntimeProviderRetryIdentity { + project_id: "project-tool-plan-handoff".to_string(), + agent_id: agent_id.to_string(), + task_id: "task-tool-plan-handoff".to_string(), + session_id: "session-tool-plan-handoff".to_string(), + run_id: run_id.to_string(), + source: "agent-background-task".to_string(), + goal_id: Some("goal-tool-plan-handoff".to_string()), + goal_revision: 4, + goal_snapshot_fingerprint: "a".repeat(64), + applied_steer_cursor: 2, + request_kind: "tool-plan".to_string(), + base_request_slot: slot.to_string(), + request_fingerprint: format!("{:x}", Sha256::digest(slot.as_bytes())), + provider_config_fingerprint: "b".repeat(64), + web_search_enabled: repair_attempt == 0, + allow_idle_context_compaction: false, + } +} + +fn response(text: &str, tool_calls: Vec) -> LlmRunResponse { + LlmRunResponse { + provider: LlmProvider::OpenAiCompatible, + model: "tool-plan-handoff-model".to_string(), + text: text.to_string(), + finish_reason: Some("tool_calls".to_string()), + response_id: Some("tool-plan-handoff-response".to_string()), + usage: Some(LlmTokenUsage { + prompt_tokens: 21, + completion_tokens: 13, + total_tokens: 34, + }), + tool_calls, + } +} + +fn call(id: &str, name: &str, arguments: &str) -> LlmToolCall { + LlmToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + } +} + +fn provider_request_id(marker: &str) -> String { + format!("provider-request-{:x}", Sha256::digest(marker.as_bytes())) +} + +fn request_slot(identity: &AgentRuntimeProviderRetryIdentity, attempt: u32) -> String { + request_slot_for_attempt(identity, attempt) +} + +fn write( + root: &Path, + identity: &AgentRuntimeProviderRetryIdentity, + attempt: u32, + response: &LlmRunResponse, +) -> AgentRuntimeToolPlanHandoffEntry { + write_at( + root, + identity, + &request_slot(identity, attempt), + attempt, + &provider_request_id(&format!("{}-{attempt}", identity.base_request_slot)), + response, + ) + .expect("write tool-plan handoff") +} + +#[test] +fn tool_plan_handoff_exact_tool_calls_round_trip_and_lookup() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let expected = response( + "执行精确工具计划。", + vec![ + call("call-1", "file.write", r#"{"path":"src/main.rs"}"#), + call("call-2", "project.verify", r#"{"unterminated":"#), + ], + ); + let entry = write(project.path(), &identity, 0, &expected); + + assert_eq!(entry.identity, identity); + assert_eq!(entry.loop_iteration, 0); + assert_eq!(entry.repair_attempt, 0); + assert_eq!(entry.to_llm_response(), expected); + assert_eq!( + lookup_at( + project.path(), + &identity.agent_id, + &identity.run_id, + &identity, + ) + .expect("lookup tool-plan handoff"), + AgentRuntimeToolPlanHandoffLookup::Exact(entry.clone()) + ); + let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read tool-plan handoff") + .expect("tool-plan handoff ledger"); + assert_eq!(ledger.entries, vec![entry]); + + #[cfg(unix)] + assert_eq!( + fs::metadata(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("tool-plan handoff metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); +} + +#[test] +fn tool_plan_handoff_strips_thinking_before_persisting() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let private_thinking = "TOOL_PLAN_PRIVATE_THINKING_MUST_NOT_PERSIST"; + let entry = write( + project.path(), + &identity, + 0, + &response( + &format!("{private_thinking}\n公开计划"), + vec![call("call-1", "update_agent_plan", "{}")], + ), + ); + + assert_eq!(entry.to_llm_response().text, "公开计划"); + assert_eq!( + entry + .thinking_normalization_metadata() + .map(|(count, chars, fingerprint)| (count, chars, fingerprint.len())), + Some(( + 1, + format!("{private_thinking}\n公开计划") + .chars() + .count(), + 64, + )) + ); + let persisted = fs::read_to_string(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("read persisted tool-plan handoff"); + assert!(!persisted.contains(private_thinking)); + assert!(!persisted.to_ascii_lowercase().contains("")); +} + +#[test] +fn tool_plan_handoff_invalid_thinking_wrappers_replay_bodyless_protocol_markers() { + let cases = [ + ( + "UNTERMINATED_PRIVATE_THINKING", + true, + false, + INVALID_THINKING_OPEN_MARKER, + ), + ( + "ORPHAN_PRIVATE_THINKING", + false, + true, + INVALID_THINKING_CLOSE_MARKER, + ), + ( + "MISMATCHED_PRIVATE_THINKING", + false, + false, + INVALID_THINKING_CLOSE_MARKER, + ), + ]; + + for (index, (source, wrapper_valid, wrapper_balanced, marker)) in cases.into_iter().enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let entry = write( + project.path(), + &identity, + 0, + &response( + source, + vec![call("call-invalid-think", "project.verify", "{}")], + ), + ); + + assert_eq!(entry.response.text, "", "case {index}"); + assert_eq!( + entry.response.thinking_wrapper_valid, wrapper_valid, + "case {index}" + ); + assert_eq!( + entry.response.thinking_wrapper_balanced, wrapper_balanced, + "case {index}" + ); + assert!(entry.response.thinking_source_text_chars > 0); + assert_eq!( + entry + .response + .thinking_source_text_sha256 + .as_deref() + .map(str::len), + Some(64) + ); + assert!(entry.thinking_normalization_metadata().is_none()); + + let replayed = entry.to_llm_response(); + assert_eq!(replayed.text, marker, "case {index}"); + assert!(crate::agent::parse_game_creator_agent_tool_plan_llm_response(&replayed).is_err()); + let persisted = fs::read_to_string(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("read persisted invalid thinking handoff"); + assert!(!persisted.contains(source)); + assert!(!persisted.contains("PRIVATE_THINKING")); + assert!(persisted.contains("\"thinkingWrapperValid\"")); + assert!(persisted.contains("\"thinkingWrapperBalanced\"")); + } +} + +#[test] +fn tool_plan_handoff_strips_nested_balanced_thinking_without_leaking_body() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let entry = write( + project.path(), + &identity, + 0, + &response( + "OUTER_PRIVATEINNER_PRIVATEvisible", + vec![call("call-balanced-think", "project.verify", "{}")], + ), + ); + + assert_eq!(entry.to_llm_response().text, "visible"); + assert!(entry.response.thinking_wrapper_valid); + assert!(entry.response.thinking_wrapper_balanced); + assert_eq!(entry.response.thinking_normalization_count, 1); + let persisted = fs::read_to_string(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("read nested thinking handoff"); + assert!(!persisted.contains("OUTER_PRIVATE")); + assert!(!persisted.contains("INNER_PRIVATE")); +} + +#[test] +fn tool_plan_handoff_appends_base_repair_and_next_loop_monotonically() { + let project = tempdir().expect("tool-plan handoff project"); + let base = identity("loop-0-repair-0"); + let repair = identity("loop-0-repair-1"); + let next_loop = identity("loop-1-repair-0"); + write(project.path(), &base, 0, &response("base", Vec::new())); + write( + project.path(), + &repair, + 2, + &response("repair", vec![call("call-r", "respond_to_user", "{}")]), + ); + write( + project.path(), + &next_loop, + 0, + &response("next loop", Vec::new()), + ); + + let ledger = read_for_run_at(project.path(), &base.agent_id, &base.run_id) + .expect("read tool-plan handoff") + .expect("tool-plan handoff ledger"); + assert_eq!( + ledger + .entries + .iter() + .map(|entry| (entry.loop_iteration, entry.repair_attempt, entry.attempt)) + .collect::>(), + vec![(0, 0, 0), (0, 1, 2), (1, 0, 0)] + ); + assert!(is_later_repair_identity(&base, &repair)); + assert!(!is_later_repair_identity(&repair, &base)); + assert!(!is_later_repair_identity(&base, &next_loop)); +} + +#[test] +fn tool_plan_handoff_same_slot_is_idempotent() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-3-repair-0"); + let expected = response( + "idempotent", + vec![call("call-idempotent", "project.verify", "{}")], + ); + let first = write(project.path(), &identity, 1, &expected); + let second = write(project.path(), &identity, 1, &expected); + assert_eq!(first, second); + assert_eq!( + read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read tool-plan handoff") + .expect("tool-plan handoff ledger") + .entries + .len(), + 1 + ); +} + +#[test] +fn tool_plan_handoff_reports_identity_and_payload_conflicts() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let expected = response("original", Vec::new()); + let entry = write(project.path(), &identity, 0, &expected); + + let mut conflicting_identity = identity.clone(); + conflicting_identity.request_fingerprint = "c".repeat(64); + assert_eq!( + lookup_at( + project.path(), + &identity.agent_id, + &identity.run_id, + &conflicting_identity, + ) + .expect("lookup conflicting tool-plan handoff"), + AgentRuntimeToolPlanHandoffLookup::IdentityConflict(entry) + ); + let error = write_at( + project.path(), + &conflicting_identity, + &conflicting_identity.base_request_slot, + 0, + &provider_request_id("conflicting-identity"), + &expected, + ) + .expect_err("conflicting identity must fail"); + assert!(error.contains("同一 slot 内容冲突")); + + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("conflicting-request-id"), + &expected, + ) + .expect_err("conflicting requestId must fail"); + assert!(error.contains("同一 slot 内容冲突")); + let error = write_at( + project.path(), + &identity, + &request_slot(&identity, 1), + 1, + &provider_request_id("conflicting-attempt"), + &expected, + ) + .expect_err("conflicting attempt must fail"); + assert!(error.contains("同一 slot 内容冲突")); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("loop-0-repair-0-0"), + &response("different response", Vec::new()), + ) + .expect_err("conflicting response must fail"); + assert!(error.contains("同一 slot 内容冲突")); +} + +#[test] +fn tool_plan_handoff_rejects_out_of_order_entries() { + let project = tempdir().expect("tool-plan handoff project"); + let repair_without_base = identity("loop-0-repair-1"); + let error = write_at( + project.path(), + &repair_without_base, + &repair_without_base.base_request_slot, + 0, + &provider_request_id("repair-without-base"), + &response("repair", Vec::new()), + ) + .expect_err("repair without base must fail"); + assert!(error.contains("repair-0")); + + let base = identity("loop-0-repair-0"); + write(project.path(), &base, 0, &response("base", Vec::new())); + let repair_gap = identity("loop-0-repair-2"); + let error = write_at( + project.path(), + &repair_gap, + &repair_gap.base_request_slot, + 0, + &provider_request_id("repair-gap"), + &response("repair gap", Vec::new()), + ) + .expect_err("repair gap must fail"); + assert!(error.contains("连续追加")); + + let mut drifted_repair = identity("loop-0-repair-1"); + drifted_repair.goal_revision += 1; + let error = write_at( + project.path(), + &drifted_repair, + &drifted_repair.base_request_slot, + 0, + &provider_request_id("repair-identity-drift"), + &response("repair identity drift", Vec::new()), + ) + .expect_err("same loop repair identity drift must fail"); + assert!(error.contains("repair 链身份冲突")); + + let next_loop_repair = identity("loop-1-repair-1"); + let error = write_at( + project.path(), + &next_loop_repair, + &next_loop_repair.base_request_slot, + 0, + &provider_request_id("next-loop-repair"), + &response("next loop repair", Vec::new()), + ) + .expect_err("new loop repair must start at zero"); + assert!(error.contains("repair-0")); + + let future = identity("loop-2-repair-0"); + write( + project.path(), + &future, + 0, + &response("future loop", Vec::new()), + ); + let missing_middle = identity("loop-1-repair-0"); + let error = lookup_at( + project.path(), + &missing_middle.agent_id, + &missing_middle.run_id, + &missing_middle, + ) + .expect_err("future entry must prevent replaying an older missing request"); + assert!(error.contains("未来 entry")); + + let ledger = read_for_run_at(project.path(), &base.agent_id, &base.run_id) + .expect("read ordered handoff") + .expect("ordered handoff ledger"); + assert_eq!(ledger.entries.len(), 2); +} + +#[test] +fn tool_plan_handoff_rejects_dangerous_content_and_invalid_calls_without_writing() { + let cases = [ + response("load .env.local", Vec::new()), + response( + "safe text", + vec![call("call-path", "file.write", r#"{"path":"/etc/passwd"}"#)], + ), + response( + "safe text", + vec![call( + "call-escaped-path", + "file.write", + r#"{"path":"\u002fetc\u002fpasswd"}"#, + )], + ), + response( + "safe text", + vec![call("call-secret", "file.write", r#"{"api_key":"secret"}"#)], + ), + response("safe text", vec![call("", "file.write", "{}")]), + response( + "safe text", + vec![call("call-control", "file.\nwrite", "{}")], + ), + ]; + + for (index, response) in cases.into_iter().enumerate() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("dangerous-{index}")), + &response, + ) + .expect_err("dangerous tool-plan handoff must fail"); + assert!( + error.contains("敏感") || error.contains("绝对路径") || error.contains("无效"), + "unexpected error: {error}" + ); + assert!( + !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,).exists() + ); + } + + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let project_path_argument = format!(r#"{{"path":"{}"}}"#, project.path().display()); + write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("project-path"), + &response( + "safe text", + vec![call( + "call-project-path", + "file.write", + &project_path_argument, + )], + ), + ) + .expect_err("project path must fail"); + assert!( + !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,).exists() + ); +} + +#[test] +fn tool_plan_handoff_rejects_sensitive_keys_in_valid_and_malformed_json() { + let sensitive_keys = [ + "x-api-key", + "apiKey", + "token", + "access_token", + "refresh_token", + "password", + "client_secret", + "private_key", + "credential", + "openai_api_key", + "github_token", + "db_password", + "webhook_secret", + "secrets", + "clientSecrets", + "secretKey", + "apiKeys", + "privateKeys", + "credentials", + ]; + for (index, key) in sensitive_keys.into_iter().enumerate() { + for (shape, arguments) in [ + ("valid", format!(r#"{{"{key}":"placeholder"}}"#)), + ("malformed", format!(r#"{{"{key}":"placeholder""#)), + ( + "commented", + format!(r#"{{"{key}"/*untrusted*/:"placeholder"}}"#), + ), + ] { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("sensitive-key-{index}-{shape}")), + &response( + "safe text", + vec![call("call-sensitive-key", "project.verify", &arguments)], + ), + ) + .expect_err("sensitive JSON keys must fail closed"); + assert!(error.contains("敏感 JSON key"), "unexpected error: {error}"); + assert!( + !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,) + .exists() + ); + } + } + + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let unicode_key = r#"{"to\u006ben":"placeholder""#; + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("unicode-sensitive-key"), + &response( + "safe text", + vec![call("call-unicode-key", "project.verify", unicode_key)], + ), + ) + .expect_err("escaped sensitive JSON key must fail closed"); + assert!(error.contains("敏感 JSON key")); +} + +#[test] +fn tool_plan_handoff_rejects_nested_and_malformed_absolute_path_inputs() { + for (index, arguments) in [ + r#"{"content":{"path":"/home/user/private"}}"#, + r#"{"path":"/home/user/private""#, + r#"{"path":"/home/user/private"#, + r#"{paths:["/home/user/private"]"#, + r#"{"path":"C:\Users\alice"}"#, + r#"{"path":"C:\Users\alice"#, + r#"{"bad\uZZZZ":"x","path":"/etc/passwd"}"#, + r#"{"path"/*untrusted*/:"/etc/passwd"}"#, + r#"{"path":/*untrusted*/"C:\Users\alice"}"#, + ] + .into_iter() + .enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("absolute-path-shape-{index}")), + &response( + "safe text", + vec![call("call-absolute-path", "file.write", arguments)], + ), + ) + .expect_err("nested and malformed absolute paths must fail closed"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); + } + + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("malformed-text-absolute-path"), + &response(r#"{"path":"/etc/passwd"#, Vec::new()), + ) + .expect_err("malformed text JSON absolute path must fail closed"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); +} + +#[test] +fn tool_plan_handoff_allows_ordinary_source_in_content_html_and_patch_fields() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let arguments = serde_json::json!({ + "path": "src/security-form.ts", + "content": "const token = props.token; const apiKey = options.apiKey; const samplePath = '/home/example';", + "html": "", + "patch": "const defaults = { client_secret: label, private_key: fieldName };" + }) + .to_string(); + let expected = response( + "safe source plan", + vec![call("call-source-fields", "file.write", &arguments)], + ); + + let entry = write(project.path(), &identity, 0, &expected); + assert_eq!(entry.to_llm_response(), expected); +} + +#[test] +fn tool_plan_handoff_rejects_secrets_and_absolute_paths_in_short_metadata() { + enum MetadataField { + Model, + FinishReason, + ResponseId, + ToolCallId, + ToolCallName, + } + + for (index, (field, value)) in [ + (MetadataField::Model, "sk-0123456789abcdef"), + (MetadataField::Model, "/tmp/private-model"), + (MetadataField::FinishReason, "sk-0123456789abcdef"), + (MetadataField::FinishReason, "/tmp/private-finish"), + (MetadataField::ResponseId, "sk-0123456789abcdef"), + (MetadataField::ResponseId, "/tmp/private-response"), + (MetadataField::ToolCallId, "sk-0123456789abcdef"), + (MetadataField::ToolCallId, "/tmp/private-call-id"), + (MetadataField::ToolCallName, "credential"), + (MetadataField::ToolCallName, "/tmp/private-call-name"), + ] + .into_iter() + .enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let mut candidate = response("safe text", vec![call("call-safe", "project.verify", "{}")]); + match field { + MetadataField::Model => candidate.model = value.to_string(), + MetadataField::FinishReason => { + candidate.finish_reason = Some(value.to_string()); + } + MetadataField::ResponseId => candidate.response_id = Some(value.to_string()), + MetadataField::ToolCallId => candidate.tool_calls[0].id = value.to_string(), + MetadataField::ToolCallName => candidate.tool_calls[0].name = value.to_string(), + } + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("unsafe-short-metadata-{index}")), + &candidate, + ) + .expect_err("unsafe short metadata must fail closed"); + assert!( + error.contains("敏感") || error.contains("绝对路径"), + "unexpected error: {error}" + ); + assert!( + !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,).exists() + ); + } +} + +#[test] +fn tool_plan_handoff_allows_source_content_without_treating_html_tags_as_paths() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let plan = serde_json::json!({ + "thinkingSummary": "写入可玩页面", + "planUpdate": null, + "plan": ["写入 game/index.html"], + "actions": [{ + "tool": "file.write", + "reason": "写入 HTML", + "input": { + "path": "game/index.html", + "content": "" + } + }], + "response": "" + }) + .to_string(); + let entry = write(project.path(), &identity, 0, &response(&plan, Vec::new())); + assert_eq!(entry.to_llm_response().text, plan); +} + +#[test] +fn tool_plan_handoff_rejects_call_argument_entry_and_total_limits() { + let project = tempdir().expect("tool-plan handoff project"); + let base_identity = identity("loop-0-repair-0"); + let too_many_calls = (0..=TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS) + .map(|index| call(&format!("call-{index}"), "project.verify", "{}")) + .collect(); + assert!(write_at( + project.path(), + &base_identity, + &base_identity.base_request_slot, + 0, + &provider_request_id("too-many-calls"), + &response("calls", too_many_calls), + ) + .expect_err("tool call count must be bounded") + .contains("tool calls")); + + let too_large_argument = "x".repeat(TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES + 1); + assert!(write_at( + project.path(), + &base_identity, + &base_identity.base_request_slot, + 0, + &provider_request_id("too-large-argument"), + &response( + "argument", + vec![call("call-large", "project.verify", &too_large_argument)], + ), + ) + .expect_err("tool arguments must be bounded") + .contains("arguments")); + + let total_too_large = (0..17) + .map(|index| { + call( + &format!("call-total-{index}"), + "project.verify", + &"x".repeat(TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES), + ) + }) + .collect(); + assert!(write_at( + project.path(), + &base_identity, + &base_identity.base_request_slot, + 0, + &provider_request_id("total-too-large"), + &response("total", total_too_large), + ) + .expect_err("tool-plan ledger bytes must be bounded") + .contains("字节上限")); + + let entries = (0..TOOL_PLAN_HANDOFF_MAX_ENTRIES) + .map(|index| { + let identity = identity(&format!("loop-{index}-repair-0")); + let response = response(&format!("entry-{index}"), Vec::new()); + let persisted = response_for_persistence(project.path(), &response) + .expect("build persisted response"); + AgentRuntimeToolPlanHandoffEntry { + identity, + provider_request_id: provider_request_id(&format!("entry-{index}")), + request_slot: format!("loop-{index}-repair-0"), + attempt: 0, + loop_iteration: index as u64, + repair_attempt: 0, + response_fingerprint: response_fingerprint(&persisted) + .expect("fingerprint response"), + response: persisted, + created_at_ms: index as u64 + 1, + } + }) + .collect::>(); + let ledger = AgentRuntimeToolPlanHandoffLedger { + schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), + agent_id: base_identity.agent_id.clone(), + run_id: base_identity.run_id.clone(), + entries, + }; + validate_ledger(project.path(), &ledger).expect("validate full handoff ledger"); + write_agent_runtime_json_sidecar_with_max_bytes( + project.path(), + &tool_plan_handoff_relative_path(&base_identity.agent_id, &base_identity.run_id), + TOOL_PLAN_HANDOFF_LABEL, + &ledger, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + ) + .expect("write full handoff ledger"); + let next = identity(&format!("loop-{TOOL_PLAN_HANDOFF_MAX_ENTRIES}-repair-0")); + assert!( + ensure_capacity_for_request_at(project.path(), &next.agent_id, &next.run_id, &next,) + .expect_err("entry capacity must fail before a Provider request") + .contains("请求前账本容量") + ); + assert!(write_at( + project.path(), + &next, + &next.base_request_slot, + 0, + &provider_request_id("entry-overflow"), + &response("overflow", Vec::new()), + ) + .expect_err("entry count must be bounded") + .contains("条上限")); +} + +#[test] +fn tool_plan_handoff_rejects_insufficient_byte_reserve_before_provider_request() { + let project = tempdir().expect("tool-plan handoff project"); + let base_identity = identity("loop-0-repair-0"); + let mut entries = Vec::new(); + while entries.len() < TOOL_PLAN_HANDOFF_MAX_ENTRIES { + let index = entries.len(); + let entry_identity = identity(&format!("loop-{index}-repair-0")); + let persisted = + response_for_persistence(project.path(), &response(&"x".repeat(220_000), Vec::new())) + .expect("build byte reserve response"); + entries.push(AgentRuntimeToolPlanHandoffEntry { + identity: entry_identity, + provider_request_id: provider_request_id(&format!("byte-reserve-{index}")), + request_slot: format!("loop-{index}-repair-0"), + attempt: 0, + loop_iteration: index as u64, + repair_attempt: 0, + response_fingerprint: response_fingerprint(&persisted) + .expect("fingerprint byte reserve response"), + response: persisted, + created_at_ms: index as u64 + 1, + }); + let candidate = AgentRuntimeToolPlanHandoffLedger { + schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), + agent_id: base_identity.agent_id.clone(), + run_id: base_identity.run_id.clone(), + entries: entries.clone(), + }; + let bytes = serde_json::to_vec_pretty(&candidate) + .expect("serialize byte reserve candidate") + .len() + + 1; + if bytes.saturating_add(TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES) + > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + && bytes <= TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + { + validate_ledger(project.path(), &candidate).expect("validate byte reserve ledger"); + write_agent_runtime_json_sidecar_with_max_bytes( + project.path(), + &tool_plan_handoff_relative_path(&base_identity.agent_id, &base_identity.run_id), + TOOL_PLAN_HANDOFF_LABEL, + &candidate, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + ) + .expect("write byte reserve ledger"); + let next = identity(&format!("loop-{}-repair-0", entries.len())); + let error = + ensure_capacity_for_request_at(project.path(), &next.agent_id, &next.run_id, &next) + .expect_err("byte reserve must fail before a Provider request"); + assert!(error.contains("请求前账本剩余空间")); + return; + } + if bytes > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + break; + } + } + panic!("test fixture did not reach the request reserve boundary"); +} + +#[test] +fn tool_plan_handoff_strict_read_rejects_unknown_and_corrupt_fields() { + fn mutate_and_read_error(mutate: impl FnOnce(&mut serde_json::Value)) -> String { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write( + project.path(), + &identity, + 0, + &response("strict", Vec::new()), + ); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let mut value: serde_json::Value = + serde_json::from_slice(&fs::read(&path).expect("read tool-plan handoff bytes")) + .expect("parse tool-plan handoff JSON"); + mutate(&mut value); + fs::write( + &path, + serde_json::to_vec_pretty(&value).expect("serialize mutated handoff"), + ) + .expect("write mutated handoff"); + read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect_err("mutated handoff must fail") + } + + let error = mutate_and_read_error(|value| { + value + .as_object_mut() + .expect("ledger object") + .insert("unknownField".to_string(), serde_json::json!(true)); + }); + assert!(error.contains("unknown field")); + + let error = mutate_and_read_error(|value| { + value["entries"][0]["responseFingerprint"] = serde_json::json!("0".repeat(64)); + }); + assert!(error.contains("responseFingerprint")); + + let error = mutate_and_read_error(|value| { + value["entries"][0]["requestSlot"] = serde_json::json!("loop-0-repair-0-transient-1"); + }); + assert!(error.contains("requestSlot/attempt")); + + let error = mutate_and_read_error(|value| { + value["entries"][0]["providerRequestId"] = serde_json::json!("invalid-request-id"); + }); + assert!(error.contains("providerRequestId")); + + let error = mutate_and_read_error(|value| { + value["agentId"] = serde_json::json!("other-agent"); + }); + assert!(error.contains("Agent/run")); +} + +#[test] +fn tool_plan_handoff_previous_recovers_and_remove_deletes_both_copies() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let entry = write( + project.path(), + &identity, + 0, + &response("recover", Vec::new()), + ); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + fs::rename(&path, &backup_path).expect("move handoff to previous"); + assert_eq!( + read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("recover previous handoff") + .expect("recovered handoff") + .entries, + vec![entry] + ); + + fs::copy(&backup_path, &path).expect("restore primary while retaining previous"); + remove_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("remove both handoff copies"); + assert!(!path.exists()); + assert!(!backup_path.exists()); + remove_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("repeat handoff removal"); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_read_uses_open_agent_directory_after_path_replacement() { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-read-agent-directory"); + let external_primary = external + .path() + .join(path.file_name().expect("primary file name")); + fs::write(&external_primary, b"external primary must remain").expect("write external primary"); + fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) + .expect("set external primary permissions"); + + let error = read_for_run_at_unix_with_agent_open_hook( + project.path(), + &identity.agent_id, + &identity.run_id, + |_| { + fs::rename(&agent_directory, &displaced).expect("displace opened read directory"); + symlink(external.path(), &agent_directory) + .expect("replace read directory with symlink"); + }, + ) + .expect_err("replaced read directory must fail closed after fixed-handle read"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert_eq!( + fs::read(&external_primary).expect("read external primary"), + b"external primary must remain" + ); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore read directory"); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_write_uses_open_agent_directory_after_path_replacement() { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read handoff ledger") + .expect("handoff ledger"); + let bytes = serialize_ledger_for_storage(&ledger).expect("serialize handoff ledger"); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-write-agent-directory"); + let external_primary = external + .path() + .join(path.file_name().expect("primary file name")); + fs::write(&external_primary, b"external primary must remain").expect("write external primary"); + fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) + .expect("set external primary permissions"); + + let error = write_ledger_at_unix_with_agent_open_hook(project.path(), &ledger, &bytes, |_| { + fs::rename(&agent_directory, &displaced).expect("displace opened write directory"); + symlink(external.path(), &agent_directory).expect("replace write directory with symlink"); + }) + .expect_err("replaced write directory must fail closed after fixed-handle write"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert_eq!( + fs::read(&external_primary).expect("read external primary"), + b"external primary must remain" + ); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore write directory"); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_write_rolls_back_when_temp_name_is_rebound_before_install() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let original_primary = fs::read(&path).expect("read original primary"); + let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read handoff ledger") + .expect("handoff ledger"); + let bytes = serialize_ledger_for_storage(&ledger).expect("serialize handoff ledger"); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let displaced_temp = agent_directory.join("displaced-writer-temp"); + + let error = write_ledger_at_unix_with_hooks( + project.path(), + &ledger, + &bytes, + |_| {}, + |temporary_name| { + let temporary_path = agent_directory.join(temporary_name); + fs::rename(&temporary_path, &displaced_temp).expect("displace locked writer temp"); + fs::write(&temporary_path, b"replacement temp must not become primary") + .expect("write replacement temp"); + fs::set_permissions(&temporary_path, fs::Permissions::from_mode(0o600)) + .expect("set replacement temp permissions"); + }, + ) + .expect_err("rebound temp name must fail and restore the original primary"); + assert!(error.contains("身份冲突"), "unexpected error: {error}"); + assert_eq!( + fs::read(&path).expect("read restored primary"), + original_primary + ); + assert!(displaced_temp.exists()); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_remove_rejects_file_name_rebinding_before_unlink() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .expect("primary file name") + .to_string(); + let displaced = path.with_file_name("displaced-delete-ledger"); + let storage = open_unix_tool_plan_agent_storage(project.path(), &identity.agent_id, false) + .expect("open handoff storage") + .expect("handoff storage"); + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + ) + .expect("lock handoff agent directory"); + + let error = remove_unix_tool_plan_file_at_with_hook( + &storage.agent_directory, + &file_name, + "tool-plan 成功响应交接账本", + |_| { + fs::rename(&path, &displaced).expect("displace opened primary"); + fs::write(&path, b"replacement must remain").expect("write replacement primary"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("set replacement permissions"); + }, + ) + .expect_err("rebound delete name must fail before unlink"); + assert!(error.contains("名称换绑"), "unexpected error: {error}"); + assert_eq!( + fs::read(&path).expect("read replacement primary"), + b"replacement must remain" + ); + assert!(displaced.exists()); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_remove_uses_open_agent_directory_after_path_replacement() { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + fs::copy(&path, &backup_path).expect("copy handoff previous"); + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) + .expect("set previous permissions"); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-remove-agent-directory"); + let external_primary = external + .path() + .join(path.file_name().expect("primary file name")); + let external_previous = external + .path() + .join(backup_path.file_name().expect("previous file name")); + fs::write(&external_primary, b"external primary must remain").expect("write external primary"); + fs::write(&external_previous, b"external previous must remain") + .expect("write external previous"); + fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) + .expect("set external primary permissions"); + fs::set_permissions(&external_previous, fs::Permissions::from_mode(0o600)) + .expect("set external previous permissions"); + + let error = remove_at_unix_with_agent_open_hook( + project.path(), + &identity.agent_id, + &identity.run_id, + |_| { + fs::rename(&agent_directory, &displaced).expect("displace opened remove directory"); + symlink(external.path(), &agent_directory) + .expect("replace remove directory with symlink"); + }, + ) + .expect_err("replaced remove directory must fail closed after fixed-handle deletion"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert!(external_primary.exists()); + assert!(external_previous.exists()); + assert!(!displaced + .join(path.file_name().expect("primary name")) + .exists()); + assert!(!displaced + .join(backup_path.file_name().expect("previous name")) + .exists()); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore remove directory"); +} + +#[test] +fn tool_plan_handoff_list_discovers_sorted_ledgers_validates_previous_and_cleans_safe_temp() { + let project = tempdir().expect("tool-plan handoff project"); + let identities = [ + identity_for("loop-0-repair-0", "quality-review", "run-2"), + identity_for("loop-0-repair-0", "design-director", "run-2"), + identity_for("loop-0-repair-0", "design-director", "run-1"), + ]; + for identity in &identities { + write(project.path(), identity, 0, &response("base", Vec::new())); + } + + let base = &identities[1]; + let path = tool_plan_handoff_path(project.path(), &base.agent_id, &base.run_id); + let base_bytes = fs::read(&path).expect("read base handoff bytes"); + let repair = identity_for("loop-0-repair-1", &base.agent_id, &base.run_id); + write( + project.path(), + &repair, + 0, + &response("repair", vec![call("call-repair", "project.verify", "{}")]), + ); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + fs::write(&backup_path, base_bytes).expect("restore valid previous prefix"); + #[cfg(unix)] + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) + .expect("set previous permissions"); + + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.5678", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + i32::MAX, + )); + fs::write(&temp_path, b"partial atomic write").expect("write stale temp"); + #[cfg(unix)] + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set stale temp permissions"); + + let listed = list_at(project.path()).expect("list tool-plan handoffs"); + assert!(!temp_path.exists()); + assert_eq!( + listed + .iter() + .map(|ledger| (ledger.agent_id(), ledger.run_id(), ledger.entries.len())) + .collect::>(), + vec![ + ("design-director", "run-1", 1), + ("design-director", "run-2", 2), + ("quality-review", "run-2", 1), + ] + ); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_list_preserves_active_atomic_temp_file() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + std::process::id(), + provider_retry::now_ms(), + )); + let mut temp_file = std::fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(&temp_path) + .expect("create active temp"); + temp_file + .write_all(b"active atomic write") + .expect("write active temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set active temp permissions"); + // SAFETY: the test owns temp_file and intentionally models the writer lock. + assert_eq!( + unsafe { libc::flock(temp_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + 0 + ); + + let error = list_at(project.path()).expect_err("active temp must keep recovery busy"); + assert!(error.contains("活跃写入句柄"), "unexpected error: {error}"); + assert!(temp_path.exists()); + + drop(temp_file); + list_at(project.path()).expect("clean unlocked temp after writer closes"); + assert!(!temp_path.exists()); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_list_cleans_unlocked_atomic_temp_with_reused_pid() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + std::process::id(), + provider_retry::now_ms(), + )); + fs::write(&temp_path, b"stale temp from reused pid").expect("write stale temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set stale temp permissions"); + + list_at(project.path()).expect("same pid without writer lock is stale"); + assert!(!temp_path.exists()); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_temp_cleanup_rejects_name_rebinding_before_unlink() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + std::process::id(), + provider_retry::now_ms(), + )); + fs::write(&temp_path, b"stale temp").expect("write stale temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set stale temp permissions"); + let file_name = temp_path + .file_name() + .and_then(|value| value.to_str()) + .expect("temp file name") + .to_string(); + let displaced = temp_path.with_file_name("displaced-stale-temp"); + let storage = open_unix_tool_plan_agent_storage(project.path(), &identity.agent_id, false) + .expect("open handoff storage") + .expect("handoff storage"); + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + ) + .expect("lock handoff agent directory"); + + let error = remove_stale_unix_handoff_temp_file_at_with_hook( + &storage.agent_directory, + &file_name, + |_| { + fs::rename(&temp_path, &displaced).expect("displace locked stale temp"); + fs::write(&temp_path, b"replacement must remain").expect("write replacement temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set replacement permissions"); + }, + ) + .expect_err("rebound stale temp name must fail before unlink"); + assert!(error.contains("名称换绑"), "unexpected error: {error}"); + assert_eq!( + fs::read(&temp_path).expect("read replacement temp"), + b"replacement must remain" + ); + assert!(displaced.exists()); +} + +#[cfg(windows)] +#[test] +fn tool_plan_handoff_list_preserves_exclusively_open_windows_temp_file() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let storage = open_windows_tool_plan_agent_storage(project.path(), &identity.agent_id, false) + .expect("open Windows handoff storage") + .expect("Windows handoff storage"); + let run_key = path_key(&identity.run_id); + let (temp_name, mut temp_file) = + create_windows_tool_plan_temp_file_at(&storage.agent_directory, &run_key) + .expect("create exclusive Windows temp"); + temp_file + .write_all(b"active atomic write") + .expect("write active Windows temp"); + temp_file.sync_data().expect("sync active Windows temp"); + let temp_path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id) + .with_file_name(&temp_name); + + let error = list_at(project.path()).expect_err("exclusive temp must keep recovery busy"); + assert!( + temp_path.exists(), + "active Windows temp must remain: {error}" + ); + + drop(temp_file); + list_at(project.path()).expect("clean Windows temp after writer closes"); + assert!(!temp_path.exists()); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_list_detects_agent_directory_replacement_without_touching_external_files() { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-agent-directory"); + let external_marker = external.path().join(format!( + ".{}.tmp.{}.1", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + i32::MAX, + )); + fs::write(&external_marker, b"must remain").expect("write external marker"); + fs::set_permissions(&external_marker, fs::Permissions::from_mode(0o600)) + .expect("set external marker permissions"); + + let error = list_at_unix_with_agent_open_hook(project.path(), |agent_key| { + if agent_key == path_key(&identity.agent_id) { + fs::rename(&agent_directory, &displaced).expect("displace opened agent directory"); + symlink(external.path(), &agent_directory) + .expect("replace agent directory with symlink"); + } + }) + .expect_err("replaced agent directory must fail closed"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert!(external_marker.exists()); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore agent directory"); +} + +#[test] +fn tool_plan_handoff_list_rejects_unknown_hash_paths_and_divergent_previous() { + let unknown_project = tempdir().expect("tool-plan handoff project"); + let unknown_identity = identity("loop-0-repair-0"); + write( + unknown_project.path(), + &unknown_identity, + 0, + &response("unknown", Vec::new()), + ); + let unknown_agent_directory = tool_plan_handoff_path( + unknown_project.path(), + &unknown_identity.agent_id, + &unknown_identity.run_id, + ) + .parent() + .expect("handoff agent directory") + .to_path_buf(); + fs::write(unknown_agent_directory.join("unexpected.txt"), b"unknown") + .expect("write unknown handoff entry"); + let error = list_at(unknown_project.path()).expect_err("unknown entry must fail closed"); + assert!(error.contains("未知文件"), "unexpected error: {error}"); + + let hash_project = tempdir().expect("tool-plan handoff project"); + let hash_identity = identity("loop-0-repair-0"); + let hash_path = tool_plan_handoff_path( + hash_project.path(), + &hash_identity.agent_id, + &hash_identity.run_id, + ); + write( + hash_project.path(), + &hash_identity, + 0, + &response("hash", Vec::new()), + ); + let hash_agent_directory = hash_path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let wrong_agent_directory = hash_agent_directory + .parent() + .expect("handoff root directory") + .join("0".repeat(64)); + fs::rename(&hash_agent_directory, &wrong_agent_directory) + .expect("move handoff under wrong hash"); + let error = list_at(hash_project.path()).expect_err("wrong hash path must fail closed"); + assert!(error.contains("hash 路径"), "unexpected error: {error}"); + + let primary_project = tempdir().expect("tool-plan handoff project"); + let conflicting_project = tempdir().expect("tool-plan handoff project"); + let conflict_identity = identity("loop-0-repair-0"); + write( + primary_project.path(), + &conflict_identity, + 0, + &response("primary", Vec::new()), + ); + write( + conflicting_project.path(), + &conflict_identity, + 0, + &response("divergent previous", Vec::new()), + ); + let primary_path = tool_plan_handoff_path( + primary_project.path(), + &conflict_identity.agent_id, + &conflict_identity.run_id, + ); + let conflicting_path = tool_plan_handoff_path( + conflicting_project.path(), + &conflict_identity.agent_id, + &conflict_identity.run_id, + ); + let previous_path = agent_runtime_json_sidecar_backup_path(&primary_path); + fs::copy(conflicting_path, &previous_path).expect("install divergent previous"); + #[cfg(unix)] + fs::set_permissions(&previous_path, fs::Permissions::from_mode(0o600)) + .expect("set divergent previous permissions"); + let error = list_at(primary_project.path()) + .expect_err("divergent primary and previous must fail closed"); + assert!(error.contains("primary/.previous 内容冲突")); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_list_only_cleans_0600_single_link_atomic_temp_files() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let bad_mode_temp = path.with_file_name(format!( + ".{}.tmp.42.100", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name") + )); + fs::write(&bad_mode_temp, b"unsafe mode temp").expect("write bad mode temp"); + fs::set_permissions(&bad_mode_temp, fs::Permissions::from_mode(0o644)) + .expect("set bad temp permissions"); + let error = list_at(project.path()).expect_err("bad temp mode must fail closed"); + assert!(error.contains("0600")); + assert!(bad_mode_temp.exists()); + + fs::set_permissions(&bad_mode_temp, fs::Permissions::from_mode(0o600)) + .expect("repair temp permissions"); + let linked_temp = path.with_file_name(format!( + ".{}.tmp.42.101", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name") + )); + fs::hard_link(&bad_mode_temp, &linked_temp).expect("hard link temp file"); + let error = list_at(project.path()).expect_err("linked temp must fail closed"); + assert!(error.contains("单链接")); + assert!(bad_mode_temp.exists()); + assert!(linked_temp.exists()); +} + +#[cfg(unix)] +#[test] +fn tool_plan_handoff_list_rejects_symlinked_root_agent_and_run_entries() { + use std::os::unix::fs::symlink; + + let root_project = tempdir().expect("tool-plan handoff project"); + let external_directory = tempdir().expect("external handoff directory"); + let handoff_root = root_project + .path() + .join(TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY); + fs::create_dir_all( + handoff_root + .parent() + .expect("tool-plan handoff runtime directory"), + ) + .expect("create runtime directory"); + symlink(external_directory.path(), &handoff_root).expect("symlink handoff root"); + let error = list_at(root_project.path()).expect_err("symlinked root must fail closed"); + assert!(error.contains("根目录"), "unexpected error: {error}"); + + let agent_project = tempdir().expect("tool-plan handoff project"); + let handoff_root = agent_project + .path() + .join(TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY); + fs::create_dir_all(&handoff_root).expect("create handoff root"); + symlink(external_directory.path(), handoff_root.join("a".repeat(64))) + .expect("symlink handoff agent"); + let error = list_at(agent_project.path()).expect_err("symlinked agent must fail closed"); + assert!(error.contains("Agent 目录"), "unexpected error: {error}"); + + let run_project = tempdir().expect("tool-plan handoff project"); + let run_identity = identity("loop-0-repair-0"); + write( + run_project.path(), + &run_identity, + 0, + &response("run symlink", Vec::new()), + ); + let run_path = tool_plan_handoff_path( + run_project.path(), + &run_identity.agent_id, + &run_identity.run_id, + ); + fs::remove_file(&run_path).expect("remove primary before symlink"); + let external_file = external_directory.path().join("external.json"); + fs::write(&external_file, b"{}").expect("write external file"); + symlink(&external_file, &run_path).expect("symlink handoff run"); + let error = list_at(run_project.path()).expect_err("symlinked run must fail closed"); + assert!(error.contains("账本文件"), "unexpected error: {error}"); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/thinking.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/thinking.rs new file mode 100644 index 000000000..f039f99e0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/thinking.rs @@ -0,0 +1,139 @@ +use sha2::{Digest, Sha256}; + +pub(super) struct PersistedThinkingNormalization { + pub(super) persisted_text: String, + pub(super) wrapper_valid: bool, + pub(super) wrapper_balanced: bool, + pub(super) saw_wrapper: bool, + pub(super) complete_block_count: u32, + pub(super) source_text_chars: usize, + pub(super) source_text_sha256: Option, +} + +pub(super) fn normalize_thinking_for_persistence(value: &str) -> PersistedThinkingNormalization { + const THINK_START: &str = ""; + const THINK_END: &str = ""; + let lower = value.to_ascii_lowercase(); + let mut output = String::new(); + let mut cursor = 0usize; + let mut scan = 0usize; + let mut depth = 0u32; + let mut count = 0u32; + let mut saw_wrapper = false; + let mut wrapper_valid = true; + loop { + let next_start = lower[scan..].find(THINK_START).map(|index| scan + index); + let next_end = lower[scan..].find(THINK_END).map(|index| scan + index); + match (next_start, next_end) { + (Some(start), Some(end)) if start < end => { + saw_wrapper = true; + if depth == 0 { + output.push_str(&value[cursor..start]); + } + depth = depth.saturating_add(1); + scan = start + THINK_START.len(); + } + (Some(start), None) => { + saw_wrapper = true; + if depth == 0 { + output.push_str(&value[cursor..start]); + } + depth = depth.saturating_add(1); + scan = start + THINK_START.len(); + } + (_, Some(end)) => { + saw_wrapper = true; + scan = end + THINK_END.len(); + if depth == 0 { + wrapper_valid = false; + continue; + } + depth -= 1; + if depth == 0 { + cursor = scan; + count = count.saturating_add(1); + } + } + (None, None) => break, + } + } + let wrapper_balanced = depth == 0; + let persisted_text = if wrapper_valid && wrapper_balanced { + output.push_str(&value[cursor..]); + output.trim().to_string() + } else { + String::new() + }; + let (source_text_chars, source_text_sha256) = if saw_wrapper { + ( + value.chars().count(), + Some(format!("{:x}", Sha256::digest(value.as_bytes()))), + ) + } else { + (0, None) + }; + PersistedThinkingNormalization { + persisted_text, + wrapper_valid, + wrapper_balanced, + saw_wrapper, + complete_block_count: count, + source_text_chars, + source_text_sha256, + } +} + +pub(super) fn decode_json_escaped_scan_view(value: &str) -> String { + let bytes = value.as_bytes(); + let mut output = String::with_capacity(value.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'\\' { + let character = value[index..].chars().next().unwrap_or_default(); + output.push(character); + index += character.len_utf8(); + continue; + } + let Some(escaped) = bytes.get(index + 1).copied() else { + output.push('\\'); + break; + }; + match escaped { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{0008}'), + b'f' => output.push('\u{000c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => { + let Some(hex) = value.get(index + 2..index.saturating_add(6)) else { + output.push('\\'); + index += 1; + continue; + }; + let Ok(codepoint) = u32::from_str_radix(hex, 16) else { + output.push('\\'); + index += 1; + continue; + }; + let Some(character) = char::from_u32(codepoint) else { + output.push('\\'); + index += 1; + continue; + }; + output.push(character); + index += 6; + continue; + } + _ => { + output.push('\\'); + index += 1; + continue; + } + } + index += 2; + } + output +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 31d7c048c..08699e8b0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5252,3 +5252,12 @@ - 父级收束:父 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` 暂留后由主线程清理。 + +## 2026-07-22 AI 游戏创作客户端第三轮四 Agent 并行结构拆分 + +- 决策:第三轮继续由四个 Agent 按互不重叠的文件边界并行搬迁大型 Rust 模块,入口文件保持稳定 facade,不改变既有函数名、测试名、调用路径、公开字段或业务行为。`tool_plan_handoff.rs` 从 `5614` 行降到 `24` 行并拆为 `10` 个子模块;最大生产模块为 Unix `1219` 行、Windows `1040` 行,测试模块为 `1697` 行。Unix 与 Windows 文件存储分别承载完整的平台原子提交链,为保持平台内原子语义不再按行数机械切分。 +- 生成与终端:`agent/generation.rs` 从 `4566` 行降到 `92` 行并拆为 `10` 个子模块,最大生产模块 `trace.rs` 为 `834` 行;原有 `123` 个 `pub(crate)` API 由 facade 显式重导出,仅 `4` 个确需跨 `crate::agent` 使用的 helper 最小化调整为 `pub(in crate::agent)`。`swarm_cli.rs` 从 `4420` 行降到 `68` 行并拆为 `9` 个子模块,最大生产模块 `observer.rs` 为 `843` 行、测试模块为 `1529` 行;原 `35` 个测试名以及 `turn.report` 的字段和顺序保持不变。 +- 浏览器:`browser.rs` 从 `4036` 行降到 `24` 行并拆为 `11` 个子模块,最大生产模块 `capture.rs` 为 `733` 行,`playtest/mod.rs` 为 `612` 行,测试模块为 `1169` 行;搬迁前后内嵌 raw JavaScript 的哈希一致。 +- 集成边界:集成修复只补齐 `tool_plan_handoff` 下沉测试不再继承父模块作用域后缺失的 `response_fingerprint`、`validate_ledger` 与 `AsRawFd` import,并对兼容重导出添加局部 `#[allow(unused_imports)]`;未删除兼容出口,编译警告总数仍为 `18`。 +- 验收:客户端 crate 的 `cargo fmt --check`、`cargo check` 与 `cargo check --tests` 通过;`tool_plan_handoff` 为 `44/44`,`swarm_cli` 为 `35/35`,`browser` 为 `21 passed / 3 real Chrome ignored`。Linux 串行全量为 `1146 passed / 5 ignored / 0 failed`。确定性真实 Runner + Chrome E2E 为 **PASS**,Provider lifecycle `17/17`,项目 revision `0 -> 2`,固定试玩 `37/37`,终局残留与泄漏均为 `0`。 +- 残余验证缺口:Windows cross check 在进入项目代码前即因宿主缺少 `x86_64-w64-mingw32-gcc` 而停止;本轮不能据此宣称 Windows 交叉编译已通过,需在补齐宿主交叉链接器后复验。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b5b971610..9d6c372fc 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3528,3 +3528,10 @@ - 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` 暂留后由主线程清理。 + +## 第三轮并行拆分要分开处理测试作用域、兼容出口和稳定树验收 + +- 现象:把父文件中的测试整体下沉到 `tests.rs` 后,原来可直接使用的 helper、validator 或平台 trait 突然无法解析;本轮具体缺失的是 `response_fingerprint`、`validate_ledger` 与 Unix `AsRawFd`。与此同时,facade 兼容重导出会出现 `unused_imports`,并行写入期间启动全 crate 编译还可能读到其它 Agent 尚未完成的中间态。 +- 原因:Rust 子模块不会继承父模块的私有 `use` 作用域;兼容重导出的价值是维持旧调用面,不能用当前 facade 是否直接消费来判断;多个 Agent 即使写入范围互不重叠,全 crate 编译仍会读取全部模块,因而无法避开正在落盘的半成品。 +- 处理:测试下沉时显式补齐自身依赖的 import,不把生产可见性为测试统一放宽。已确认属于旧调用面的重导出必须保留,只在精确重导出位置添加局部 `#[allow(unused_imports)]`,不得按 warning 机械删除或全局 suppress。并行阶段禁止启动全 crate 编译、全量测试和真实 E2E;各 Agent 只执行自己边界内的检查,待所有写入方完成后由主线程在稳定共享树统一验收。 +- 验证:稳定树统一运行 `cargo fmt --check`、`cargo check`、`cargo check --tests`、三个定向测试组、Linux 串行全量和确定性真实 Runner + Chrome E2E;同时核对原测试名、`turn.report` 字段/顺序、raw JavaScript 哈希和兼容重导出。Windows cross check 若因宿主缺少交叉链接器而未进入项目代码,必须明确记录为残余验证缺口,不能写成项目代码已通过。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 44a8af9e9..b2cb2f693 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -693,3 +693,7 @@ game-project/ - 同轮后续收口把 `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` 行,因为它承载现有单一主循环函数;下一轮只能在识别运行状态阶段后按阶段拆分,不能按行数机械切割。 - 第二轮拆分继续保留稳定 facade。仅供同一父模块下兄弟子模块调用的 helper 使用 `pub(super)`;访问被 `runtime_protocol::provider_retry` 遮蔽的 crate 根同名模块时显式使用 `crate::provider_retry`。原有兼容重导出不能按 `unused_imports` 告警机械删除,确认是旧调用面后只添加局部 `#[allow(unused_imports)]`。 - 结构拆分的稳定树门禁为 Rust 串行全量 `1139 passed / 5 ignored / 0 failed`、客户端前端 `329/329`、typecheck、客户端 crate fmt、Prettier、ESLint、encoding,以及确定性 E2E self-test 和完整 E2E 均 PASS。完整 E2E 的项目 revision 为 `0 -> 2`,真实浏览器固定试玩 `37/37`,重复、残留和泄漏均为 `0`。并行写入期间发生在 CLI 构建阶段、尚未创建 run 的 exit `101` 不计作 Runtime E2E 结果。 +- 2026-07-22 的第三轮由四个 Agent 继续按稳定 facade 并行拆分:`tool_plan_handoff.rs` 从 `5614` 行降到 `24` 行并拆为 `10` 个子模块,最大生产模块为 Unix `1219` 行、Windows `1040` 行,测试模块为 `1697` 行;平台存储模块各自保留完整原子提交链,不再按行数机械切分。`agent/generation.rs` 从 `4566` 行降到 `92` 行并拆为 `10` 个子模块,最大生产模块 `trace.rs` 为 `834` 行,原 `123` 个 `pub(crate)` API 由 facade 显式重导出,仅 `4` 个跨 `crate::agent` helper 最小化为 `pub(in crate::agent)`。 +- 同轮 `swarm_cli.rs` 从 `4420` 行降到 `68` 行并拆为 `9` 个子模块,最大生产模块 `observer.rs` 为 `843` 行、测试模块为 `1529` 行;原 `35` 个测试名与 `turn.report` 字段/顺序不变。`browser.rs` 从 `4036` 行降到 `24` 行并拆为 `11` 个子模块,最大生产模块 `capture.rs` 为 `733` 行、`playtest/mod.rs` 为 `612` 行,测试模块为 `1169` 行;内嵌 raw JavaScript 的搬迁前后哈希一致。 +- 第三轮集成修复仅补 `tool_plan_handoff` 下沉测试缺失的 `response_fingerprint / validate_ledger / AsRawFd` import,并为兼容重导出添加局部 `#[allow(unused_imports)]`;兼容出口未删除,警告总数仍为 `18`。测试子模块不继承父模块 `use`,后续拆分必须显式补 import;并行写入期禁止全 crate 编译,统一门禁只能在四个 Agent 全部完成后的稳定共享树运行。 +- 第三轮稳定树已通过 `cargo fmt --check`、`cargo check`、`cargo check --tests`,以及 `tool_plan_handoff 44/44`、`swarm_cli 35/35`、`browser 21 passed / 3 real Chrome ignored`;Linux 串行全量为 `1146 passed / 5 ignored / 0 failed`。确定性真实 Runner + Chrome E2E 为 **PASS**,Provider lifecycle `17/17`、revision `0 -> 2`、固定试玩 `37/37`,残留与泄漏均为 `0`。Windows cross check 因宿主缺少 `x86_64-w64-mingw32-gcc`,在进入项目代码前停止,仍是明确的残余验证缺口。