Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
2.3 MiB
Plaintext

{"repo": "carlosfengv/zerondesign", "files": {"services/runtime/tests/agent_loop.rs": {"3b43b5e9cf9e581536a6312c6967d8376dc99c3f": {"t": 1785745871.031723, "n": 202563, "h": "aa54d54e14b5a9c4", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n runtime::run_with_test_runtime,\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRun, AgentRunStatus,\n Brief, ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n future::Future,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::sync::Mutex;\n\nfn run_with_agent_test_stack<F>(name: &str, future: F)\nwhere\n F: Future<Output = ()> + Send + 'static,\n{\n run_with_test_runtime(name, future);\n}\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"next-app\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"next-app\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\nfn workflow_progress_text(request: &ModelRequest) -> &str {\n request\n .messages\n .iter()\n .find(|message| {\n message.get(\"kind\").and_then(Value::as_str) == Some(\"runtime_workflow_progress\")\n })\n .and_then(|message| message.get(\"text\"))\n .and_then(Value::as_str)\n .expect(\"model request should include runtime workflow progress\")\n}\n\nasync fn create_legacy_build_run_at_source_authoring(\n store: &RuntimeStore,\n project_id: &str,\n model: &str,\n) -> AgentRun {\n let brief_owner = store\n .create_run(\n project_id.to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n model.to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_owner.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"test users\".to_string(),\n content_hierarchy: vec![\"Home\".to_string()],\n page_structure: json!([{\n \"title\": \"Home\",\n \"purpose\": \"Exercise the agent-loop test harness\",\n \"keyContent\": [\"test fixture\"]\n }]),\n visual_direction: \"test fixture\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let run = store\n .create_run_with_context(\n project_id.to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model.to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let substantive_progress = json!([\"test-fixture:source-authoring-ready\"]);\n let state = json!({\n \"completedSteps\": [\n \"inputs_inventoried\",\n \"brief_loaded\",\n \"content_sources_loaded\",\n \"project_initialized\",\n \"project_inspected\"\n ],\n \"substantiveProgress\": substantive_progress.clone(),\n });\n let fingerprint = canonical_json_hash(&json!({\n \"schemaVersion\": \"substantive-progress-ledger@1\",\n \"entries\": substantive_progress,\n }));\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: run.id.clone(),\n turn: 0,\n fingerprint,\n consecutive_no_progress: 0,\n evidence: json!({ \"state\": state }),\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n run\n}\n\n#[test]\nfn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n run_with_agent_test_stack(\n \"brief_run_prompt_stays_on_content_sources_without_workspace_exploration\",\n brief_run_prompt_stays_on_content_sources_without_workspace_exploration_inner(),\n );\n}\n\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Next.js website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"next-app for app and website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[test]\nfn brief_first_model_turn_preserves_user_request_without_content_sources() {\n run_with_agent_test_stack(\n \"brief-first-turn-operation-request\",\n brief_first_model_turn_preserves_user_request_without_content_sources_inner(),\n );\n}\n\nasync fn brief_first_model_turn_preserves_user_request_without_content_sources_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-brief-user-intake\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![],\n )\n .await;\n let user_request =\n \"Create an App for triaging customer incidents with owner and severity filters.\";\n let request_hash = canonical_json_hash(&json!({\n \"domain\": \"operation-user-request@1\",\n \"userRequest\": user_request,\n }));\n store\n .append_operation_user_message(\n &run.id,\n user_request,\n \"message-brief-intake-1\",\n &request_hash,\n 1,\n Some(\"when_ambiguous\"),\n Some(\"app\"),\n None,\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after first model turn\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == user_request\n }));\n}\n\n#[test]\nfn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n run_with_agent_test_stack(\n \"build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn\",\n build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn_inner(),\n );\n}\n\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn_inner() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"Runtime-owned next-app Draft workflow\"));\n assert!(requests[0].system_prompt.contains(\"First inventory inputs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"project.ensure_dependencies\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Never call preview.publish or preview.start for next-app\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[test]\nfn build_run_bootstraps_design_profile_json_and_markdown() {\n run_with_agent_test_stack(\n \"build_run_bootstraps_design_profile_json_and_markdown\",\n build_run_bootstraps_design_profile_json_and_markdown_inner(),\n );\n}\n\nasync fn build_run_bootstraps_design_profile_json_and_markdown_inner() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[test]\nfn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n run_with_agent_test_stack(\n \"imported_profile_bootstrap_preserves_source_bytes_and_writes_index\",\n imported_profile_bootstrap_preserves_source_bytes_and_writes_index_inner(),\n );\n}\n\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index_inner() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"next-app\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[test]\nfn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n run_with_agent_test_stack(\n \"docs_build_run_bootstraps_design_profile_json_and_markdown\",\n docs_build_run_bootstraps_design_profile_json_and_markdown_inner(),\n );\n}\n\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown_inner() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[test]\nfn edit_run_bootstraps_design_profile_json_and_markdown() {\n run_with_agent_test_stack(\n \"edit_run_bootstraps_design_profile_json_and_markdown\",\n edit_run_bootstraps_design_profile_json_and_markdown_inner(),\n );\n}\n\nasync fn edit_run_bootstraps_design_profile_json_and_markdown_inner() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[test]\nfn review_run_prompt_uses_design_profile_for_drift_findings() {\n run_with_agent_test_stack(\n \"review_run_prompt_uses_design_profile_for_drift_findings\",\n review_run_prompt_uses_design_profile_for_drift_findings_inner(),\n );\n}\n\nasync fn review_run_prompt_uses_design_profile_for_drift_findings_inner() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"read inputs/design-profile.json and inputs/design.md only when present\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[test]\nfn child_review_does_not_replay_parent_checkpoint_transcript() {\n run_with_agent_test_stack(\n \"child_review_does_not_replay_parent_checkpoint_transcript\",\n child_review_does_not_replay_parent_checkpoint_transcript_inner(),\n );\n}\n\nasync fn child_review_does_not_replay_parent_checkpoint_transcript_inner() {\n let store = RuntimeStore::new();\n let parent = store\n .create_run(\n \"project-review-sidechain\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &parent.project_id,\n Some(&parent.id),\n \"user_message\",\n Some(\"user\"),\n \"Inspect deliberate marker REVIEW_TARGET_MARKER for zero contrast\".to_string(),\n None,\n )\n .await;\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-parent-edit-sidechain\".to_string(),\n run_id: parent.id.clone(),\n project_id: parent.project_id.clone(),\n phase: parent.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 7,\n \"text\": \"SETUP EDIT COMPLETE; DO NOT REPLAY IN REVIEW\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"parent edit transcript\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let review = store\n .create_child_run(\n &parent.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after sidechain assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&review.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].turn, 1);\n assert!(!requests[0]\n .messages\n .iter()\n .any(|message| message.to_string().contains(\"SETUP EDIT COMPLETE\")));\n let review_target = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_review_target\")\n .expect(\"Review target should be the latest scoped user message\");\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"REVIEW_TARGET_MARKER\"));\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"review.report_finding\"));\n}\n\n#[test]\nfn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n run_with_agent_test_stack(\n \"repair_run_prompt_includes_only_runtime_validated_target_finding_details\",\n repair_run_prompt_includes_only_runtime_validated_target_finding_details_inner(),\n );\n}\n\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details_inner() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[test]\nfn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n run_with_agent_test_stack(\n \"bootstrap_workspace_failure_emits_tool_failed_before_run_failed\",\n bootstrap_workspace_failure_emits_tool_failed_before_run_failed_inner(),\n );\n}\n\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed_inner() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[test]\nfn run_cannot_complete_without_run_complete() {\n run_with_agent_test_stack(\n \"run_cannot_complete_without_run_complete\",\n run_cannot_complete_without_run_complete_inner(),\n );\n}\n\nasync fn run_cannot_complete_without_run_complete_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[test]\nfn three_consecutive_empty_turns_transition_to_partial() {\n run_with_agent_test_stack(\n \"three_consecutive_empty_turns_transition_to_partial\",\n three_consecutive_empty_turns_transition_to_partial_inner(),\n );\n}\n\nasync fn three_consecutive_empty_turns_transition_to_partial_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[test]\nfn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n run_with_agent_test_stack(\n \"tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse\",\n tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse_inner(),\n );\n}\n\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"runtime.tool_selection.recovery_turn_total\"\n ))\n .count(),\n 3\n );\n}\n\n#[test]\nfn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n run_with_agent_test_stack(\n \"model_turn_budget_stops_the_run_before_unbounded_iteration\",\n model_turn_budget_stops_the_run_before_unbounded_iteration_inner(),\n );\n}\n\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[test]\nfn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n run_with_agent_test_stack(\n \"tool_call_budget_preserves_tool_result_pairing_and_stops_execution\",\n tool_call_budget_preserves_tool_result_pairing_and_stops_execution_inner(),\n );\n}\n\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[test]\nfn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n run_with_agent_test_stack(\n \"input_token_budget_stops_before_tool_execution_and_preserves_pairing\",\n input_token_budget_stops_before_tool_execution_and_preserves_pairing_inner(),\n );\n}\n\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[test]\nfn output_token_budget_stops_text_response_run() {\n run_with_agent_test_stack(\n \"output_token_budget_stops_text_response_run\",\n output_token_budget_stops_text_response_run_inner(),\n );\n}\n\nasync fn output_token_budget_stops_text_response_run_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[test]\nfn recovered_token_usage_is_counted_once_per_model_turn() {\n run_with_agent_test_stack(\n \"recovered_token_usage_is_counted_once_per_model_turn\",\n recovered_token_usage_is_counted_once_per_model_turn_inner(),\n );\n}\n\nasync fn recovered_token_usage_is_counted_once_per_model_turn_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[test]\nfn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n run_with_agent_test_stack(\n \"mixed_recoverable_protocol_errors_open_the_shared_fuse\",\n mixed_recoverable_protocol_errors_open_the_shared_fuse_inner(),\n );\n}\n\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[test]\nfn protocol_error_fuse_restores_consecutive_count_after_restart() {\n run_with_agent_test_stack(\n \"protocol_error_fuse_restores_consecutive_count_after_restart\",\n protocol_error_fuse_restores_consecutive_count_after_restart_inner(),\n );\n}\n\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[test]\nfn model_error_marks_run_failed() {\n run_with_agent_test_stack(\n \"model_error_marks_run_failed\",\n model_error_marks_run_failed_inner(),\n );\n}\n\nasync fn model_error_marks_run_failed_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[test]\nfn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n run_with_agent_test_stack(\n \"model_error_after_tool_use_emits_missing_tool_result_before_failure\",\n model_error_after_tool_use_emits_missing_tool_result_before_failure_inner(),\n );\n}\n\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[test]\nfn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n run_with_agent_test_stack(\n \"tool_input_parse_failure_emits_recoverable_matching_tool_result\",\n tool_input_parse_failure_emits_recoverable_matching_tool_result_inner(),\n );\n}\n\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result_inner() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[test]\nfn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n run_with_agent_test_stack(\n \"streaming_tool_input_too_large_emits_recoverable_matching_tool_result\",\n streaming_tool_input_too_large_emits_recoverable_matching_tool_result_inner(),\n );\n}\n\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result_inner() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[test]\nfn tool_input_schema_invalid_failure_records_safe_run_health() {\n run_with_agent_test_stack(\n \"tool_input_schema_invalid_failure_records_safe_run_health\",\n tool_input_schema_invalid_failure_records_safe_run_health_inner(),\n );\n}\n\nasync fn tool_input_schema_invalid_failure_records_safe_run_health_inner() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[test]\nfn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n run_with_agent_test_stack(\n \"repeated_recoverable_large_write_failure_stops_run_as_partial\",\n repeated_recoverable_large_write_failure_stops_run_as_partial_inner(),\n );\n}\n\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial_inner() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/app/page.tsx\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/app/page.tsx\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/app/page.tsx\")\n .count(),\n 3\n );\n}\n\n#[test]\nfn repeated_typed_patch_failure_stops_run_as_partial() {\n run_with_agent_test_stack(\n \"repeated_typed_patch_failure_stops_run_as_partial\",\n repeated_typed_patch_failure_stops_run_as_partial_inner(),\n );\n}\n\nasync fn repeated_typed_patch_failure_stops_run_as_partial_inner() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let mut tools = sandbox_tools();\n let patch_tool = tools\n .iter()\n .find(|tool| tool.name() == \"fs.patch\")\n .cloned()\n .expect(\"sandbox tool set should include fs.patch\");\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.typed_patch\",\n aliases: &[\"fs.patch\"],\n inner: patch_tool,\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"mutation.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"mutation.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[test]\nfn failed_run_cleans_its_staged_chunk_sessions() {\n run_with_agent_test_stack(\n \"failed_run_cleans_its_staged_chunk_sessions\",\n failed_run_cleans_its_staged_chunk_sessions_inner(),\n );\n}\n\nasync fn failed_run_cleans_its_staged_chunk_sessions_inner() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[test]\nfn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n run_with_agent_test_stack(\n \"fallback_discards_old_tool_attempt_and_continues_next_turn\",\n fallback_discards_old_tool_attempt_and_continues_next_turn_inner(),\n );\n}\n\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[test]\nfn tool_and_run_events_are_written_to_conversation_items() {\n run_with_agent_test_stack(\n \"tool_and_run_events_are_written_to_conversation_items\",\n tool_and_run_events_are_written_to_conversation_items_inner(),\n );\n}\n\nasync fn tool_and_run_events_are_written_to_conversation_items_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[test]\nfn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n run_with_agent_test_stack(\n \"agent_loop_sends_messages_and_tool_snapshot_to_model_gateway\",\n agent_loop_sends_messages_and_tool_snapshot_to_model_gateway_inner(),\n );\n}\n\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-inspect\",\n \"project.inspect\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign App Builder runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"project.inspect\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-inspect\"\n && message[\"toolName\"] == \"project.inspect\"\n }));\n}\n\n#[test]\nfn agent_loop_includes_run_user_messages_in_model_context() {\n run_with_agent_test_stack(\n \"agent_loop_includes_run_user_messages_in_model_context\",\n agent_loop_includes_run_user_messages_in_model_context_inner(),\n );\n}\n\nasync fn agent_loop_includes_run_user_messages_in_model_context_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[test]\nfn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n run_with_agent_test_stack(\n \"agent_loop_preserves_distinct_user_messages_with_identical_text\",\n agent_loop_preserves_distinct_user_messages_with_identical_text_inner(),\n );\n}\n\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[test]\nfn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n run_with_agent_test_stack(\n \"agent_loop_merges_persisted_conversation_before_appending_after_restart\",\n agent_loop_merges_persisted_conversation_before_appending_after_restart_inner(),\n );\n}\n\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart_inner() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn approved_permission_replaces_prior_tool_error_before_model_resume() {\n run_with_agent_test_stack(\n \"approved_permission_replaces_prior_tool_error_before_model_resume\",\n approved_permission_replaces_prior_tool_error_before_model_resume_inner(),\n );\n}\n\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[test]\nfn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n run_with_agent_test_stack(\n \"rejected_approved_input_is_consumed_and_reported_once_to_model\",\n rejected_approved_input_is_consumed_and_reported_once_to_model_inner(),\n );\n}\n\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[test]\nfn agent_loop_deterministically_compacts_history_to_workspace_context() {\n run_with_agent_test_stack(\"agent-loop-compaction\", async move {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Audit\"));\n assert_eq!(context.matches(\"# Runtime Context Audit\").count(), 1);\n assert!(context.contains(\"## Semantic Summary\"));\n assert!(context.contains(\"semantic-context-summary@3\"));\n assert!(context.contains(\"## Recent Compacted Evidence\"));\n assert!(context.contains(\"tool-missing-\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 1_000);\n assert!(context.chars().count() <= 64_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"]\n == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"context_window_epoch_advanced\"\n && metadata[\"triggerReasons\"].as_array().is_some_and(|reasons| !reasons.is_empty())\n && metadata[\"conversationBytes\"].as_u64().is_some()\n && metadata[\"largestMessageBytes\"].as_u64().is_some()\n )));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n metadata: Some(metadata),\n ..\n } if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n });\n}\n\n#[test]\nfn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n run_with_agent_test_stack(\n \"terminal_tool_error_marks_tool_failed_as_not_recoverable\",\n terminal_tool_error_marks_tool_failed_as_not_recoverable_inner(),\n );\n}\n\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[test]\nfn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n run_with_agent_test_stack(\n \"shell_non_zero_exit_emits_recoverable_tool_failed_event\",\n shell_non_zero_exit_emits_recoverable_tool_failed_event_inner(),\n );\n}\n\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event_inner() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let mut tools = sandbox_tools();\n let shell_tool = tools\n .iter()\n .find(|tool| tool.name() == \"shell.run\")\n .cloned()\n .expect(\"sandbox tool set should include shell.run\");\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.shell_run\",\n aliases: &[\"shell.run\"],\n inner: shell_tool,\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[test]\nfn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n run_with_agent_test_stack(\n \"continue_interrupt_synthetic_failure_is_not_recoverable_in_events\",\n continue_interrupt_synthetic_failure_is_not_recoverable_in_events_inner(),\n );\n}\n\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nfn real_deepseek_design_md_website_generation_e2e() {\n run_with_agent_test_stack(\n \"real_deepseek_design_md_website_generation_e2e\",\n real_deepseek_design_md_website_generation_e2e_inner(),\n );\n}\n\nasync fn real_deepseek_design_md_website_generation_e2e_inner() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[test]\nfn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n run_with_agent_test_stack(\n \"streaming_tool_executor_enforces_runtime_wall_clock_deadline\",\n streaming_tool_executor_enforces_runtime_wall_clock_deadline_inner(),\n );\n}\n\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[test]\nfn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n run_with_agent_test_stack(\n \"streaming_tool_executor_uses_extended_deadline_for_build_tools\",\n streaming_tool_executor_uses_extended_deadline_for_build_tools_inner(),\n );\n}\n\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[test]\nfn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n run_with_agent_test_stack(\n \"tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed\",\n tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed_inner(),\n );\n}\n\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed_inner() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/other/page.tsx\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn failed_mutation_result_is_replayed_without_reexecution() {\n run_with_agent_test_stack(\n \"failed_mutation_result_is_replayed_without_reexecution\",\n failed_mutation_result_is_replayed_without_reexecution_inner(),\n );\n}\n\nasync fn failed_mutation_result_is_replayed_without_reexecution_inner() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n run_with_agent_test_stack(\n \"tool_execution_ledger_omits_raw_output_and_uses_private_permissions\",\n tool_execution_ledger_omits_raw_output_and_uses_private_permissions_inner(),\n );\n}\n\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions_inner() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n run_with_agent_test_stack(\n \"read_only_tool_results_are_not_persisted_in_execution_ledger\",\n read_only_tool_results_are_not_persisted_in_execution_ledger_inner(),\n );\n}\n\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger_inner() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn in_doubt_mutation_is_paused_without_reexecution() {\n run_with_agent_test_stack(\n \"in_doubt_mutation_is_paused_without_reexecution\",\n in_doubt_mutation_is_paused_without_reexecution_inner(),\n );\n}\n\nasync fn in_doubt_mutation_is_paused_without_reexecution_inner() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n run_with_agent_test_stack(\n \"agent_loop_idle_watchdog_structurally_stops_stalled_model_request\",\n agent_loop_idle_watchdog_structurally_stops_stalled_model_request_inner(),\n );\n}\n\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[test]\nfn agent_loop_total_watchdog_bounds_active_run() {\n run_with_agent_test_stack(\n \"agent-loop-total-watchdog\",\n agent_loop_total_watchdog_bounds_active_run_inner(),\n );\n}\n\nasync fn agent_loop_total_watchdog_bounds_active_run_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[test]\nfn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n run_with_agent_test_stack(\n \"no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns\",\n no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns_inner(),\n );\n}\n\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[test]\nfn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n run_with_agent_test_stack(\n \"source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run\",\n source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run_inner(),\n );\n}\n\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run_inner() {\n let workspace = unique_temp_dir(\"agent-loop-source-mutation-progress\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.extend([\n Arc::new(ObserveTool) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }) as Arc<dyn Tool>,\n ]);\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let events = store.events(&run.id).await;\n let counters = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[test]\nfn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n run_with_agent_test_stack(\n \"chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path\",\n chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path_inner(),\n );\n}\n\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path_inner() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-progress\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.sequenced_chunk_commit\",\n aliases: &[\"fs.commit_chunks\"],\n inner: Arc::new(SequencedChunkCommitTool::default()),\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/page.tsx\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[test]\nfn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n run_with_agent_test_stack(\n \"distinct_staged_chunks_count_as_progress_without_marking_source_authored\",\n distinct_staged_chunks_count_as_progress_without_marking_source_authored_inner(),\n );\n}\n\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[test]\nfn file_reads_are_observations_and_do_not_reset_no_progress() {\n run_with_agent_test_stack(\n \"file_reads_are_observations_and_do_not_reset_no_progress\",\n file_reads_are_observations_and_do_not_reset_no_progress_inner(),\n );\n}\n\nasync fn file_reads_are_observations_and_do_not_reset_no_progress_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 2]);\n}\n\n#[test]\nfn observation_budgets_warn_without_rejecting_tools() {\n run_with_agent_test_stack(\n \"observation_budgets_warn_without_rejecting_tools\",\n observation_budgets_warn_without_rejecting_tools_inner(),\n );\n}\n\nasync fn observation_budgets_warn_without_rejecting_tools_inner() {\n let workspace = unique_temp_dir(\"agent-loop-observation-budget\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.extend([\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.search_observation\",\n aliases: &[\"fs.search\"],\n inner: Arc::new(SearchObservationTool),\n }) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }) as Arc<dyn Tool>,\n ]);\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (2, 2)]);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, metadata, .. }\n if name == \"run_observation_budget_warning\"\n && metadata.as_ref().and_then(|value| value.get(\"blocking\")).and_then(Value::as_bool) == Some(false)\n )));\n}\n\n#[test]\nfn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n run_with_agent_test_stack(\n \"candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes\",\n candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes_inner(),\n );\n}\n\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(TestAliasedTool {\n test_name: \"test.preview_reject\",\n aliases: &[\"preview.publish\"],\n inner: Arc::new(PreviewRejectTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_repair_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(!third_repair_read.is_error);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[test]\nfn build_failure_activates_repair_observation_budget() {\n run_with_agent_test_stack(\n \"build_failure_activates_repair_observation_budget\",\n build_failure_activates_repair_observation_budget_inner(),\n );\n}\n\nasync fn build_failure_activates_repair_observation_budget_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(TestAliasedTool {\n test_name: \"test.preview_build_fail\",\n aliases: &[\"preview.publish\"],\n inner: Arc::new(PreviewBuildFailTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(!third_read.is_error);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[test]\nfn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n run_with_agent_test_stack(\n \"missing_dependency_publish_failure_enters_repair_before_no_progress_stop\",\n missing_dependency_publish_failure_enters_repair_before_no_progress_stop_inner(),\n );\n}\n\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[1]).contains(\"diagnostic_required\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[test]\nfn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n run_with_agent_test_stack(\n \"exhausted_repair_budget_directs_mutation_instead_of_more_observation\",\n exhausted_repair_budget_directs_mutation_instead_of_more_observation_inner(),\n );\n}\n\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[2]).contains(\"diagnostic_required\"));\n assert!(workflow_progress_text(&requests[3]).contains(\"diagnostic_required\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[test]\nfn exhausted_repair_read_budget_keeps_diagnostic_tools_available() {\n run_with_agent_test_stack(\n \"exhausted_repair_read_budget_keeps_diagnostic_tools_available\",\n exhausted_repair_read_budget_keeps_diagnostic_tools_available_inner(),\n );\n}\n\nasync fn exhausted_repair_read_budget_keeps_diagnostic_tools_available_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[test]\nfn repair_mutation_directs_immediate_publish_without_more_exploration() {\n run_with_agent_test_stack(\n \"repair_mutation_directs_immediate_publish_without_more_exploration\",\n repair_mutation_directs_immediate_publish_without_more_exploration_inner(),\n );\n}\n\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[3]).contains(\"preview_ready_required\"));\n assert!(workflow_progress_text(&requests[3])\n .contains(\"bounded repair mutation needs Candidate validation\"));\n}\n\n#[test]\nfn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n run_with_agent_test_stack(\n \"build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining\",\n build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining_inner(),\n );\n}\n\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining_inner() {\n let workspace = unique_temp_dir(\"agent-loop-workflow-progress\");\n let store = RuntimeStore::new();\n let run =\n create_legacy_build_run_at_source_authoring(&store, \"workflow-progress-project\", \"test\")\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after workflow prompt capture\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(workflow_progress_text(&requests[0]).contains(\"source_authoring\"));\n assert!(workflow_progress_text(&requests[0]).contains(\"project_initialized\"));\n assert!(workflow_progress_text(&requests[0]).contains(\"\\\"remaining\\\":2\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/app/page.tsx\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"source_authoring\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\nstruct TestAliasedTool {\n test_name: &'static str,\n aliases: &'static [&'static str],\n inner: Arc<dyn Tool>,\n}\n\n#[async_trait]\nimpl Tool for TestAliasedTool {\n fn name(&self) -> &str {\n self.test_name\n }\n\n fn aliases(&self) -> &'static [&'static str] {\n self.aliases\n }\n\n fn input_schema(&self) -> Value {\n self.inner.input_schema()\n }\n\n fn input_json_schema(&self) -> Option<Value> {\n self.inner.input_json_schema()\n }\n\n fn output_schema(&self) -> Option<Value> {\n self.inner.output_schema()\n }\n\n async fn validate_input(\n &self,\n input: Value,\n ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n self.inner.validate_input(input, ctx).await\n }\n\n async fn check_permission(&self, input: &Value, ctx: &ToolContext) -> PermissionResult {\n self.inner.check_permission(input, ctx).await\n }\n\n async fn call(\n &self,\n input: Value,\n ctx: ToolContext,\n progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.inner.call(input, ctx, progress).await\n }\n}\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"next-app\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('app/page.tsx', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/app/page.tsx\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n"}, "b1f6a6fa4b17bf1d8becf9034628de13066fdf53": {"t": 1785757429.6793995, "n": 202563, "h": "aa54d54e14b5a9c4", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n runtime::run_with_test_runtime,\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRun, AgentRunStatus,\n Brief, ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n future::Future,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::sync::Mutex;\n\nfn run_with_agent_test_stack<F>(name: &str, future: F)\nwhere\n F: Future<Output = ()> + Send + 'static,\n{\n run_with_test_runtime(name, future);\n}\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"next-app\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"next-app\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\nfn workflow_progress_text(request: &ModelRequest) -> &str {\n request\n .messages\n .iter()\n .find(|message| {\n message.get(\"kind\").and_then(Value::as_str) == Some(\"runtime_workflow_progress\")\n })\n .and_then(|message| message.get(\"text\"))\n .and_then(Value::as_str)\n .expect(\"model request should include runtime workflow progress\")\n}\n\nasync fn create_legacy_build_run_at_source_authoring(\n store: &RuntimeStore,\n project_id: &str,\n model: &str,\n) -> AgentRun {\n let brief_owner = store\n .create_run(\n project_id.to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n model.to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_owner.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"test users\".to_string(),\n content_hierarchy: vec![\"Home\".to_string()],\n page_structure: json!([{\n \"title\": \"Home\",\n \"purpose\": \"Exercise the agent-loop test harness\",\n \"keyContent\": [\"test fixture\"]\n }]),\n visual_direction: \"test fixture\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let run = store\n .create_run_with_context(\n project_id.to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model.to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let substantive_progress = json!([\"test-fixture:source-authoring-ready\"]);\n let state = json!({\n \"completedSteps\": [\n \"inputs_inventoried\",\n \"brief_loaded\",\n \"content_sources_loaded\",\n \"project_initialized\",\n \"project_inspected\"\n ],\n \"substantiveProgress\": substantive_progress.clone(),\n });\n let fingerprint = canonical_json_hash(&json!({\n \"schemaVersion\": \"substantive-progress-ledger@1\",\n \"entries\": substantive_progress,\n }));\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: run.id.clone(),\n turn: 0,\n fingerprint,\n consecutive_no_progress: 0,\n evidence: json!({ \"state\": state }),\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n run\n}\n\n#[test]\nfn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n run_with_agent_test_stack(\n \"brief_run_prompt_stays_on_content_sources_without_workspace_exploration\",\n brief_run_prompt_stays_on_content_sources_without_workspace_exploration_inner(),\n );\n}\n\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Next.js website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"next-app for app and website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[test]\nfn brief_first_model_turn_preserves_user_request_without_content_sources() {\n run_with_agent_test_stack(\n \"brief-first-turn-operation-request\",\n brief_first_model_turn_preserves_user_request_without_content_sources_inner(),\n );\n}\n\nasync fn brief_first_model_turn_preserves_user_request_without_content_sources_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-brief-user-intake\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![],\n )\n .await;\n let user_request =\n \"Create an App for triaging customer incidents with owner and severity filters.\";\n let request_hash = canonical_json_hash(&json!({\n \"domain\": \"operation-user-request@1\",\n \"userRequest\": user_request,\n }));\n store\n .append_operation_user_message(\n &run.id,\n user_request,\n \"message-brief-intake-1\",\n &request_hash,\n 1,\n Some(\"when_ambiguous\"),\n Some(\"app\"),\n None,\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after first model turn\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == user_request\n }));\n}\n\n#[test]\nfn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n run_with_agent_test_stack(\n \"build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn\",\n build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn_inner(),\n );\n}\n\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn_inner() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"Runtime-owned next-app Draft workflow\"));\n assert!(requests[0].system_prompt.contains(\"First inventory inputs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"project.ensure_dependencies\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Never call preview.publish or preview.start for next-app\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[test]\nfn build_run_bootstraps_design_profile_json_and_markdown() {\n run_with_agent_test_stack(\n \"build_run_bootstraps_design_profile_json_and_markdown\",\n build_run_bootstraps_design_profile_json_and_markdown_inner(),\n );\n}\n\nasync fn build_run_bootstraps_design_profile_json_and_markdown_inner() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[test]\nfn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n run_with_agent_test_stack(\n \"imported_profile_bootstrap_preserves_source_bytes_and_writes_index\",\n imported_profile_bootstrap_preserves_source_bytes_and_writes_index_inner(),\n );\n}\n\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index_inner() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"next-app\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[test]\nfn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n run_with_agent_test_stack(\n \"docs_build_run_bootstraps_design_profile_json_and_markdown\",\n docs_build_run_bootstraps_design_profile_json_and_markdown_inner(),\n );\n}\n\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown_inner() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[test]\nfn edit_run_bootstraps_design_profile_json_and_markdown() {\n run_with_agent_test_stack(\n \"edit_run_bootstraps_design_profile_json_and_markdown\",\n edit_run_bootstraps_design_profile_json_and_markdown_inner(),\n );\n}\n\nasync fn edit_run_bootstraps_design_profile_json_and_markdown_inner() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[test]\nfn review_run_prompt_uses_design_profile_for_drift_findings() {\n run_with_agent_test_stack(\n \"review_run_prompt_uses_design_profile_for_drift_findings\",\n review_run_prompt_uses_design_profile_for_drift_findings_inner(),\n );\n}\n\nasync fn review_run_prompt_uses_design_profile_for_drift_findings_inner() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"read inputs/design-profile.json and inputs/design.md only when present\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[test]\nfn child_review_does_not_replay_parent_checkpoint_transcript() {\n run_with_agent_test_stack(\n \"child_review_does_not_replay_parent_checkpoint_transcript\",\n child_review_does_not_replay_parent_checkpoint_transcript_inner(),\n );\n}\n\nasync fn child_review_does_not_replay_parent_checkpoint_transcript_inner() {\n let store = RuntimeStore::new();\n let parent = store\n .create_run(\n \"project-review-sidechain\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &parent.project_id,\n Some(&parent.id),\n \"user_message\",\n Some(\"user\"),\n \"Inspect deliberate marker REVIEW_TARGET_MARKER for zero contrast\".to_string(),\n None,\n )\n .await;\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-parent-edit-sidechain\".to_string(),\n run_id: parent.id.clone(),\n project_id: parent.project_id.clone(),\n phase: parent.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 7,\n \"text\": \"SETUP EDIT COMPLETE; DO NOT REPLAY IN REVIEW\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"parent edit transcript\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let review = store\n .create_child_run(\n &parent.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after sidechain assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&review.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].turn, 1);\n assert!(!requests[0]\n .messages\n .iter()\n .any(|message| message.to_string().contains(\"SETUP EDIT COMPLETE\")));\n let review_target = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_review_target\")\n .expect(\"Review target should be the latest scoped user message\");\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"REVIEW_TARGET_MARKER\"));\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"review.report_finding\"));\n}\n\n#[test]\nfn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n run_with_agent_test_stack(\n \"repair_run_prompt_includes_only_runtime_validated_target_finding_details\",\n repair_run_prompt_includes_only_runtime_validated_target_finding_details_inner(),\n );\n}\n\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details_inner() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[test]\nfn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n run_with_agent_test_stack(\n \"bootstrap_workspace_failure_emits_tool_failed_before_run_failed\",\n bootstrap_workspace_failure_emits_tool_failed_before_run_failed_inner(),\n );\n}\n\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed_inner() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[test]\nfn run_cannot_complete_without_run_complete() {\n run_with_agent_test_stack(\n \"run_cannot_complete_without_run_complete\",\n run_cannot_complete_without_run_complete_inner(),\n );\n}\n\nasync fn run_cannot_complete_without_run_complete_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[test]\nfn three_consecutive_empty_turns_transition_to_partial() {\n run_with_agent_test_stack(\n \"three_consecutive_empty_turns_transition_to_partial\",\n three_consecutive_empty_turns_transition_to_partial_inner(),\n );\n}\n\nasync fn three_consecutive_empty_turns_transition_to_partial_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[test]\nfn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n run_with_agent_test_stack(\n \"tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse\",\n tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse_inner(),\n );\n}\n\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"runtime.tool_selection.recovery_turn_total\"\n ))\n .count(),\n 3\n );\n}\n\n#[test]\nfn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n run_with_agent_test_stack(\n \"model_turn_budget_stops_the_run_before_unbounded_iteration\",\n model_turn_budget_stops_the_run_before_unbounded_iteration_inner(),\n );\n}\n\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[test]\nfn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n run_with_agent_test_stack(\n \"tool_call_budget_preserves_tool_result_pairing_and_stops_execution\",\n tool_call_budget_preserves_tool_result_pairing_and_stops_execution_inner(),\n );\n}\n\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[test]\nfn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n run_with_agent_test_stack(\n \"input_token_budget_stops_before_tool_execution_and_preserves_pairing\",\n input_token_budget_stops_before_tool_execution_and_preserves_pairing_inner(),\n );\n}\n\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[test]\nfn output_token_budget_stops_text_response_run() {\n run_with_agent_test_stack(\n \"output_token_budget_stops_text_response_run\",\n output_token_budget_stops_text_response_run_inner(),\n );\n}\n\nasync fn output_token_budget_stops_text_response_run_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[test]\nfn recovered_token_usage_is_counted_once_per_model_turn() {\n run_with_agent_test_stack(\n \"recovered_token_usage_is_counted_once_per_model_turn\",\n recovered_token_usage_is_counted_once_per_model_turn_inner(),\n );\n}\n\nasync fn recovered_token_usage_is_counted_once_per_model_turn_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[test]\nfn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n run_with_agent_test_stack(\n \"mixed_recoverable_protocol_errors_open_the_shared_fuse\",\n mixed_recoverable_protocol_errors_open_the_shared_fuse_inner(),\n );\n}\n\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[test]\nfn protocol_error_fuse_restores_consecutive_count_after_restart() {\n run_with_agent_test_stack(\n \"protocol_error_fuse_restores_consecutive_count_after_restart\",\n protocol_error_fuse_restores_consecutive_count_after_restart_inner(),\n );\n}\n\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[test]\nfn model_error_marks_run_failed() {\n run_with_agent_test_stack(\n \"model_error_marks_run_failed\",\n model_error_marks_run_failed_inner(),\n );\n}\n\nasync fn model_error_marks_run_failed_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[test]\nfn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n run_with_agent_test_stack(\n \"model_error_after_tool_use_emits_missing_tool_result_before_failure\",\n model_error_after_tool_use_emits_missing_tool_result_before_failure_inner(),\n );\n}\n\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[test]\nfn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n run_with_agent_test_stack(\n \"tool_input_parse_failure_emits_recoverable_matching_tool_result\",\n tool_input_parse_failure_emits_recoverable_matching_tool_result_inner(),\n );\n}\n\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result_inner() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[test]\nfn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n run_with_agent_test_stack(\n \"streaming_tool_input_too_large_emits_recoverable_matching_tool_result\",\n streaming_tool_input_too_large_emits_recoverable_matching_tool_result_inner(),\n );\n}\n\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result_inner() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[test]\nfn tool_input_schema_invalid_failure_records_safe_run_health() {\n run_with_agent_test_stack(\n \"tool_input_schema_invalid_failure_records_safe_run_health\",\n tool_input_schema_invalid_failure_records_safe_run_health_inner(),\n );\n}\n\nasync fn tool_input_schema_invalid_failure_records_safe_run_health_inner() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[test]\nfn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n run_with_agent_test_stack(\n \"repeated_recoverable_large_write_failure_stops_run_as_partial\",\n repeated_recoverable_large_write_failure_stops_run_as_partial_inner(),\n );\n}\n\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial_inner() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/app/page.tsx\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/app/page.tsx\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/app/page.tsx\")\n .count(),\n 3\n );\n}\n\n#[test]\nfn repeated_typed_patch_failure_stops_run_as_partial() {\n run_with_agent_test_stack(\n \"repeated_typed_patch_failure_stops_run_as_partial\",\n repeated_typed_patch_failure_stops_run_as_partial_inner(),\n );\n}\n\nasync fn repeated_typed_patch_failure_stops_run_as_partial_inner() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let mut tools = sandbox_tools();\n let patch_tool = tools\n .iter()\n .find(|tool| tool.name() == \"fs.patch\")\n .cloned()\n .expect(\"sandbox tool set should include fs.patch\");\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.typed_patch\",\n aliases: &[\"fs.patch\"],\n inner: patch_tool,\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"mutation.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"mutation.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[test]\nfn failed_run_cleans_its_staged_chunk_sessions() {\n run_with_agent_test_stack(\n \"failed_run_cleans_its_staged_chunk_sessions\",\n failed_run_cleans_its_staged_chunk_sessions_inner(),\n );\n}\n\nasync fn failed_run_cleans_its_staged_chunk_sessions_inner() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[test]\nfn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n run_with_agent_test_stack(\n \"fallback_discards_old_tool_attempt_and_continues_next_turn\",\n fallback_discards_old_tool_attempt_and_continues_next_turn_inner(),\n );\n}\n\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[test]\nfn tool_and_run_events_are_written_to_conversation_items() {\n run_with_agent_test_stack(\n \"tool_and_run_events_are_written_to_conversation_items\",\n tool_and_run_events_are_written_to_conversation_items_inner(),\n );\n}\n\nasync fn tool_and_run_events_are_written_to_conversation_items_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[test]\nfn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n run_with_agent_test_stack(\n \"agent_loop_sends_messages_and_tool_snapshot_to_model_gateway\",\n agent_loop_sends_messages_and_tool_snapshot_to_model_gateway_inner(),\n );\n}\n\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-inspect\",\n \"project.inspect\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign App Builder runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"project.inspect\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-inspect\"\n && message[\"toolName\"] == \"project.inspect\"\n }));\n}\n\n#[test]\nfn agent_loop_includes_run_user_messages_in_model_context() {\n run_with_agent_test_stack(\n \"agent_loop_includes_run_user_messages_in_model_context\",\n agent_loop_includes_run_user_messages_in_model_context_inner(),\n );\n}\n\nasync fn agent_loop_includes_run_user_messages_in_model_context_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[test]\nfn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n run_with_agent_test_stack(\n \"agent_loop_preserves_distinct_user_messages_with_identical_text\",\n agent_loop_preserves_distinct_user_messages_with_identical_text_inner(),\n );\n}\n\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[test]\nfn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n run_with_agent_test_stack(\n \"agent_loop_merges_persisted_conversation_before_appending_after_restart\",\n agent_loop_merges_persisted_conversation_before_appending_after_restart_inner(),\n );\n}\n\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart_inner() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn approved_permission_replaces_prior_tool_error_before_model_resume() {\n run_with_agent_test_stack(\n \"approved_permission_replaces_prior_tool_error_before_model_resume\",\n approved_permission_replaces_prior_tool_error_before_model_resume_inner(),\n );\n}\n\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[test]\nfn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n run_with_agent_test_stack(\n \"rejected_approved_input_is_consumed_and_reported_once_to_model\",\n rejected_approved_input_is_consumed_and_reported_once_to_model_inner(),\n );\n}\n\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[test]\nfn agent_loop_deterministically_compacts_history_to_workspace_context() {\n run_with_agent_test_stack(\"agent-loop-compaction\", async move {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Audit\"));\n assert_eq!(context.matches(\"# Runtime Context Audit\").count(), 1);\n assert!(context.contains(\"## Semantic Summary\"));\n assert!(context.contains(\"semantic-context-summary@3\"));\n assert!(context.contains(\"## Recent Compacted Evidence\"));\n assert!(context.contains(\"tool-missing-\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 1_000);\n assert!(context.chars().count() <= 64_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"]\n == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"context_window_epoch_advanced\"\n && metadata[\"triggerReasons\"].as_array().is_some_and(|reasons| !reasons.is_empty())\n && metadata[\"conversationBytes\"].as_u64().is_some()\n && metadata[\"largestMessageBytes\"].as_u64().is_some()\n )));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n metadata: Some(metadata),\n ..\n } if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n });\n}\n\n#[test]\nfn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n run_with_agent_test_stack(\n \"terminal_tool_error_marks_tool_failed_as_not_recoverable\",\n terminal_tool_error_marks_tool_failed_as_not_recoverable_inner(),\n );\n}\n\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[test]\nfn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n run_with_agent_test_stack(\n \"shell_non_zero_exit_emits_recoverable_tool_failed_event\",\n shell_non_zero_exit_emits_recoverable_tool_failed_event_inner(),\n );\n}\n\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event_inner() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let mut tools = sandbox_tools();\n let shell_tool = tools\n .iter()\n .find(|tool| tool.name() == \"shell.run\")\n .cloned()\n .expect(\"sandbox tool set should include shell.run\");\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.shell_run\",\n aliases: &[\"shell.run\"],\n inner: shell_tool,\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[test]\nfn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n run_with_agent_test_stack(\n \"continue_interrupt_synthetic_failure_is_not_recoverable_in_events\",\n continue_interrupt_synthetic_failure_is_not_recoverable_in_events_inner(),\n );\n}\n\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nfn real_deepseek_design_md_website_generation_e2e() {\n run_with_agent_test_stack(\n \"real_deepseek_design_md_website_generation_e2e\",\n real_deepseek_design_md_website_generation_e2e_inner(),\n );\n}\n\nasync fn real_deepseek_design_md_website_generation_e2e_inner() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[test]\nfn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n run_with_agent_test_stack(\n \"streaming_tool_executor_enforces_runtime_wall_clock_deadline\",\n streaming_tool_executor_enforces_runtime_wall_clock_deadline_inner(),\n );\n}\n\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[test]\nfn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n run_with_agent_test_stack(\n \"streaming_tool_executor_uses_extended_deadline_for_build_tools\",\n streaming_tool_executor_uses_extended_deadline_for_build_tools_inner(),\n );\n}\n\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[test]\nfn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n run_with_agent_test_stack(\n \"tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed\",\n tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed_inner(),\n );\n}\n\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed_inner() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/other/page.tsx\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn failed_mutation_result_is_replayed_without_reexecution() {\n run_with_agent_test_stack(\n \"failed_mutation_result_is_replayed_without_reexecution\",\n failed_mutation_result_is_replayed_without_reexecution_inner(),\n );\n}\n\nasync fn failed_mutation_result_is_replayed_without_reexecution_inner() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n run_with_agent_test_stack(\n \"tool_execution_ledger_omits_raw_output_and_uses_private_permissions\",\n tool_execution_ledger_omits_raw_output_and_uses_private_permissions_inner(),\n );\n}\n\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions_inner() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n run_with_agent_test_stack(\n \"read_only_tool_results_are_not_persisted_in_execution_ledger\",\n read_only_tool_results_are_not_persisted_in_execution_ledger_inner(),\n );\n}\n\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger_inner() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn in_doubt_mutation_is_paused_without_reexecution() {\n run_with_agent_test_stack(\n \"in_doubt_mutation_is_paused_without_reexecution\",\n in_doubt_mutation_is_paused_without_reexecution_inner(),\n );\n}\n\nasync fn in_doubt_mutation_is_paused_without_reexecution_inner() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[test]\nfn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n run_with_agent_test_stack(\n \"agent_loop_idle_watchdog_structurally_stops_stalled_model_request\",\n agent_loop_idle_watchdog_structurally_stops_stalled_model_request_inner(),\n );\n}\n\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[test]\nfn agent_loop_total_watchdog_bounds_active_run() {\n run_with_agent_test_stack(\n \"agent-loop-total-watchdog\",\n agent_loop_total_watchdog_bounds_active_run_inner(),\n );\n}\n\nasync fn agent_loop_total_watchdog_bounds_active_run_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[test]\nfn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n run_with_agent_test_stack(\n \"no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns\",\n no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns_inner(),\n );\n}\n\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[test]\nfn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n run_with_agent_test_stack(\n \"source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run\",\n source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run_inner(),\n );\n}\n\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run_inner() {\n let workspace = unique_temp_dir(\"agent-loop-source-mutation-progress\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.extend([\n Arc::new(ObserveTool) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }) as Arc<dyn Tool>,\n ]);\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let events = store.events(&run.id).await;\n let counters = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[test]\nfn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n run_with_agent_test_stack(\n \"chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path\",\n chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path_inner(),\n );\n}\n\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path_inner() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-progress\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.sequenced_chunk_commit\",\n aliases: &[\"fs.commit_chunks\"],\n inner: Arc::new(SequencedChunkCommitTool::default()),\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/page.tsx\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[test]\nfn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n run_with_agent_test_stack(\n \"distinct_staged_chunks_count_as_progress_without_marking_source_authored\",\n distinct_staged_chunks_count_as_progress_without_marking_source_authored_inner(),\n );\n}\n\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[test]\nfn file_reads_are_observations_and_do_not_reset_no_progress() {\n run_with_agent_test_stack(\n \"file_reads_are_observations_and_do_not_reset_no_progress\",\n file_reads_are_observations_and_do_not_reset_no_progress_inner(),\n );\n}\n\nasync fn file_reads_are_observations_and_do_not_reset_no_progress_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 2]);\n}\n\n#[test]\nfn observation_budgets_warn_without_rejecting_tools() {\n run_with_agent_test_stack(\n \"observation_budgets_warn_without_rejecting_tools\",\n observation_budgets_warn_without_rejecting_tools_inner(),\n );\n}\n\nasync fn observation_budgets_warn_without_rejecting_tools_inner() {\n let workspace = unique_temp_dir(\"agent-loop-observation-budget\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.extend([\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.search_observation\",\n aliases: &[\"fs.search\"],\n inner: Arc::new(SearchObservationTool),\n }) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }) as Arc<dyn Tool>,\n ]);\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (2, 2)]);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, metadata, .. }\n if name == \"run_observation_budget_warning\"\n && metadata.as_ref().and_then(|value| value.get(\"blocking\")).and_then(Value::as_bool) == Some(false)\n )));\n}\n\n#[test]\nfn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n run_with_agent_test_stack(\n \"candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes\",\n candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes_inner(),\n );\n}\n\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(TestAliasedTool {\n test_name: \"test.preview_reject\",\n aliases: &[\"preview.publish\"],\n inner: Arc::new(PreviewRejectTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_repair_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(!third_repair_read.is_error);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[test]\nfn build_failure_activates_repair_observation_budget() {\n run_with_agent_test_stack(\n \"build_failure_activates_repair_observation_budget\",\n build_failure_activates_repair_observation_budget_inner(),\n );\n}\n\nasync fn build_failure_activates_repair_observation_budget_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(TestAliasedTool {\n test_name: \"test.preview_build_fail\",\n aliases: &[\"preview.publish\"],\n inner: Arc::new(PreviewBuildFailTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(!third_read.is_error);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[test]\nfn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n run_with_agent_test_stack(\n \"missing_dependency_publish_failure_enters_repair_before_no_progress_stop\",\n missing_dependency_publish_failure_enters_repair_before_no_progress_stop_inner(),\n );\n}\n\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[1]).contains(\"diagnostic_required\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[test]\nfn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n run_with_agent_test_stack(\n \"exhausted_repair_budget_directs_mutation_instead_of_more_observation\",\n exhausted_repair_budget_directs_mutation_instead_of_more_observation_inner(),\n );\n}\n\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[2]).contains(\"diagnostic_required\"));\n assert!(workflow_progress_text(&requests[3]).contains(\"diagnostic_required\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[test]\nfn exhausted_repair_read_budget_keeps_diagnostic_tools_available() {\n run_with_agent_test_stack(\n \"exhausted_repair_read_budget_keeps_diagnostic_tools_available\",\n exhausted_repair_read_budget_keeps_diagnostic_tools_available_inner(),\n );\n}\n\nasync fn exhausted_repair_read_budget_keeps_diagnostic_tools_available_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[test]\nfn repair_mutation_directs_immediate_publish_without_more_exploration() {\n run_with_agent_test_stack(\n \"repair_mutation_directs_immediate_publish_without_more_exploration\",\n repair_mutation_directs_immediate_publish_without_more_exploration_inner(),\n );\n}\n\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[3]).contains(\"preview_ready_required\"));\n assert!(workflow_progress_text(&requests[3])\n .contains(\"bounded repair mutation needs Candidate validation\"));\n}\n\n#[test]\nfn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n run_with_agent_test_stack(\n \"build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining\",\n build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining_inner(),\n );\n}\n\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining_inner() {\n let workspace = unique_temp_dir(\"agent-loop-workflow-progress\");\n let store = RuntimeStore::new();\n let run =\n create_legacy_build_run_at_source_authoring(&store, \"workflow-progress-project\", \"test\")\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after workflow prompt capture\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(workflow_progress_text(&requests[0]).contains(\"source_authoring\"));\n assert!(workflow_progress_text(&requests[0]).contains(\"project_initialized\"));\n assert!(workflow_progress_text(&requests[0]).contains(\"\\\"remaining\\\":2\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/app/page.tsx\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"source_authoring\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\nstruct TestAliasedTool {\n test_name: &'static str,\n aliases: &'static [&'static str],\n inner: Arc<dyn Tool>,\n}\n\n#[async_trait]\nimpl Tool for TestAliasedTool {\n fn name(&self) -> &str {\n self.test_name\n }\n\n fn aliases(&self) -> &'static [&'static str] {\n self.aliases\n }\n\n fn input_schema(&self) -> Value {\n self.inner.input_schema()\n }\n\n fn input_json_schema(&self) -> Option<Value> {\n self.inner.input_json_schema()\n }\n\n fn output_schema(&self) -> Option<Value> {\n self.inner.output_schema()\n }\n\n async fn validate_input(\n &self,\n input: Value,\n ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n self.inner.validate_input(input, ctx).await\n }\n\n async fn check_permission(&self, input: &Value, ctx: &ToolContext) -> PermissionResult {\n self.inner.check_permission(input, ctx).await\n }\n\n async fn call(\n &self,\n input: Value,\n ctx: ToolContext,\n progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.inner.call(input, ctx, progress).await\n }\n}\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"next-app\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('app/page.tsx', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/app/page.tsx\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n"}, "a751b00e339c1e35d38ce98e874fffd7bcdf981f": {"t": 1785757431.935579, "n": 187011, "h": "1b64192d6cdd97ec", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRun, AgentRunStatus,\n Brief, ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n future::Future,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::sync::Mutex;\n\nconst AGENT_TEST_STACK_BYTES: usize = 8 * 1024 * 1024;\n\nfn run_with_agent_test_stack<F>(name: &str, future: F)\nwhere\n F: Future<Output = ()> + Send + 'static,\n{\n std::thread::Builder::new()\n .name(name.to_string())\n .stack_size(AGENT_TEST_STACK_BYTES)\n .spawn(move || {\n tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .expect(\"agent test runtime\")\n .block_on(future);\n })\n .expect(\"agent test thread\")\n .join()\n .expect(\"agent test thread panicked\");\n}\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"next-app\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"next-app\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\nfn workflow_progress_text(request: &ModelRequest) -> &str {\n request\n .messages\n .iter()\n .find(|message| {\n message.get(\"kind\").and_then(Value::as_str) == Some(\"runtime_workflow_progress\")\n })\n .and_then(|message| message.get(\"text\"))\n .and_then(Value::as_str)\n .expect(\"model request should include runtime workflow progress\")\n}\n\nasync fn create_legacy_build_run_at_source_authoring(\n store: &RuntimeStore,\n project_id: &str,\n model: &str,\n) -> AgentRun {\n let brief_owner = store\n .create_run(\n project_id.to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n model.to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_owner.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"test users\".to_string(),\n content_hierarchy: vec![\"Home\".to_string()],\n page_structure: json!([{\n \"title\": \"Home\",\n \"purpose\": \"Exercise the agent-loop test harness\",\n \"keyContent\": [\"test fixture\"]\n }]),\n visual_direction: \"test fixture\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let run = store\n .create_run_with_context(\n project_id.to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model.to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let substantive_progress = json!([\"test-fixture:source-authoring-ready\"]);\n let state = json!({\n \"completedSteps\": [\n \"inputs_inventoried\",\n \"brief_loaded\",\n \"content_sources_loaded\",\n \"project_initialized\",\n \"project_inspected\"\n ],\n \"substantiveProgress\": substantive_progress.clone(),\n });\n let fingerprint = canonical_json_hash(&json!({\n \"schemaVersion\": \"substantive-progress-ledger@1\",\n \"entries\": substantive_progress,\n }));\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: run.id.clone(),\n turn: 0,\n fingerprint,\n consecutive_no_progress: 0,\n evidence: json!({ \"state\": state }),\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n run\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Next.js website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"next-app for app and website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[test]\nfn brief_first_model_turn_preserves_user_request_without_content_sources() {\n run_with_agent_test_stack(\n \"brief-first-turn-operation-request\",\n brief_first_model_turn_preserves_user_request_without_content_sources_inner(),\n );\n}\n\nasync fn brief_first_model_turn_preserves_user_request_without_content_sources_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-brief-user-intake\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![],\n )\n .await;\n let user_request =\n \"Create an App for triaging customer incidents with owner and severity filters.\";\n let request_hash = canonical_json_hash(&json!({\n \"domain\": \"operation-user-request@1\",\n \"userRequest\": user_request,\n }));\n store\n .append_operation_user_message(\n &run.id,\n user_request,\n \"message-brief-intake-1\",\n &request_hash,\n 1,\n Some(\"when_ambiguous\"),\n Some(\"app\"),\n None,\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after first model turn\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == user_request\n }));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"Runtime-owned next-app Draft workflow\"));\n assert!(requests[0].system_prompt.contains(\"First inventory inputs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"project.ensure_dependencies\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Never call preview.publish or preview.start for next-app\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"next-app\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"read inputs/design-profile.json and inputs/design.md only when present\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn child_review_does_not_replay_parent_checkpoint_transcript() {\n let store = RuntimeStore::new();\n let parent = store\n .create_run(\n \"project-review-sidechain\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &parent.project_id,\n Some(&parent.id),\n \"user_message\",\n Some(\"user\"),\n \"Inspect deliberate marker REVIEW_TARGET_MARKER for zero contrast\".to_string(),\n None,\n )\n .await;\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-parent-edit-sidechain\".to_string(),\n run_id: parent.id.clone(),\n project_id: parent.project_id.clone(),\n phase: parent.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 7,\n \"text\": \"SETUP EDIT COMPLETE; DO NOT REPLAY IN REVIEW\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"parent edit transcript\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let review = store\n .create_child_run(\n &parent.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after sidechain assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&review.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].turn, 1);\n assert!(!requests[0]\n .messages\n .iter()\n .any(|message| message.to_string().contains(\"SETUP EDIT COMPLETE\")));\n let review_target = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_review_target\")\n .expect(\"Review target should be the latest scoped user message\");\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"REVIEW_TARGET_MARKER\"));\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"review.report_finding\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"runtime.tool_selection.recovery_turn_total\"\n ))\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/app/page.tsx\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/app/page.tsx\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/app/page.tsx\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let mut tools = sandbox_tools();\n let patch_tool = tools\n .iter()\n .find(|tool| tool.name() == \"fs.patch\")\n .cloned()\n .expect(\"sandbox tool set should include fs.patch\");\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.typed_patch\",\n aliases: &[\"fs.patch\"],\n inner: patch_tool,\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"mutation.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"mutation.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-inspect\",\n \"project.inspect\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign App Builder runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"project.inspect\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-inspect\"\n && message[\"toolName\"] == \"project.inspect\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[tokio::test]\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[tokio::test]\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[test]\nfn agent_loop_deterministically_compacts_history_to_workspace_context() {\n run_with_agent_test_stack(\"agent-loop-compaction\", async move {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Audit\"));\n assert_eq!(context.matches(\"# Runtime Context Audit\").count(), 1);\n assert!(context.contains(\"## Semantic Summary\"));\n assert!(context.contains(\"semantic-context-summary@3\"));\n assert!(context.contains(\"## Recent Compacted Evidence\"));\n assert!(context.contains(\"tool-missing-\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 1_000);\n assert!(context.chars().count() <= 64_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"]\n == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"context_window_epoch_advanced\"\n && metadata[\"triggerReasons\"].as_array().is_some_and(|reasons| !reasons.is_empty())\n && metadata[\"conversationBytes\"].as_u64().is_some()\n && metadata[\"largestMessageBytes\"].as_u64().is_some()\n )));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n metadata: Some(metadata),\n ..\n } if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n });\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let mut tools = sandbox_tools();\n let shell_tool = tools\n .iter()\n .find(|tool| tool.name() == \"shell.run\")\n .cloned()\n .expect(\"sandbox tool set should include shell.run\");\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.shell_run\",\n aliases: &[\"shell.run\"],\n inner: shell_tool,\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/other/page.tsx\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn failed_mutation_result_is_replayed_without_reexecution() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn in_doubt_mutation_is_paused_without_reexecution() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let workspace = unique_temp_dir(\"agent-loop-source-mutation-progress\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.extend([\n Arc::new(ObserveTool) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }) as Arc<dyn Tool>,\n ]);\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let events = store.events(&run.id).await;\n let counters = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-progress\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.push(Arc::new(TestAliasedTool {\n test_name: \"test.sequenced_chunk_commit\",\n aliases: &[\"fs.commit_chunks\"],\n inner: Arc::new(SequencedChunkCommitTool::default()),\n }));\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/page.tsx\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn file_reads_are_observations_and_do_not_reset_no_progress() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_warn_without_rejecting_tools() {\n let workspace = unique_temp_dir(\"agent-loop-observation-budget\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let mut tools = sandbox_tools();\n tools.extend([\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.search_observation\",\n aliases: &[\"fs.search\"],\n inner: Arc::new(SearchObservationTool),\n }) as Arc<dyn Tool>,\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }) as Arc<dyn Tool>,\n ]);\n let executor =\n ToolExecutor::new_with_workspace_root(tools, PermissionRules::default(), &workspace);\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (2, 2)]);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, metadata, .. }\n if name == \"run_observation_budget_warning\"\n && metadata.as_ref().and_then(|value| value.get(\"blocking\")).and_then(Value::as_bool) == Some(false)\n )));\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(TestAliasedTool {\n test_name: \"test.preview_reject\",\n aliases: &[\"preview.publish\"],\n inner: Arc::new(PreviewRejectTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.successful_mutation\",\n aliases: &[\"fs.multi_patch\"],\n inner: Arc::new(SuccessfulMutationTool),\n }),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_repair_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(!third_repair_read.is_error);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(TestAliasedTool {\n test_name: \"test.preview_build_fail\",\n aliases: &[\"preview.publish\"],\n inner: Arc::new(PreviewBuildFailTool),\n }),\n Arc::new(TestAliasedTool {\n test_name: \"test.read_observation\",\n aliases: &[\"fs.read\"],\n inner: Arc::new(ReadObservationTool),\n }),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(!third_read.is_error);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[1]).contains(\"diagnostic_required\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[2]).contains(\"diagnostic_required\"));\n assert!(workflow_progress_text(&requests[3]).contains(\"diagnostic_required\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_keeps_diagnostic_tools_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[3]).contains(\"preview_ready_required\"));\n assert!(workflow_progress_text(&requests[3])\n .contains(\"bounded repair mutation needs Candidate validation\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let workspace = unique_temp_dir(\"agent-loop-workflow-progress\");\n let store = RuntimeStore::new();\n let run =\n create_legacy_build_run_at_source_authoring(&store, \"workflow-progress-project\", \"test\")\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after workflow prompt capture\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(workflow_progress_text(&requests[0]).contains(\"source_authoring\"));\n assert!(workflow_progress_text(&requests[0]).contains(\"project_initialized\"));\n assert!(workflow_progress_text(&requests[0]).contains(\"\\\"remaining\\\":2\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/app/page.tsx\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"source_authoring\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\nstruct TestAliasedTool {\n test_name: &'static str,\n aliases: &'static [&'static str],\n inner: Arc<dyn Tool>,\n}\n\n#[async_trait]\nimpl Tool for TestAliasedTool {\n fn name(&self) -> &str {\n self.test_name\n }\n\n fn aliases(&self) -> &'static [&'static str] {\n self.aliases\n }\n\n fn input_schema(&self) -> Value {\n self.inner.input_schema()\n }\n\n fn input_json_schema(&self) -> Option<Value> {\n self.inner.input_json_schema()\n }\n\n fn output_schema(&self) -> Option<Value> {\n self.inner.output_schema()\n }\n\n async fn validate_input(\n &self,\n input: Value,\n ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n self.inner.validate_input(input, ctx).await\n }\n\n async fn check_permission(&self, input: &Value, ctx: &ToolContext) -> PermissionResult {\n self.inner.check_permission(input, ctx).await\n }\n\n async fn call(\n &self,\n input: Value,\n ctx: ToolContext,\n progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.inner.call(input, ctx, progress).await\n }\n}\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"next-app\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('app/page.tsx', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/app/page.tsx\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n"}, "643ac38523c53be9ebcc5c287a31f5221db1ffd6": {"t": 1785757433.9668436, "n": 181632, "h": "f441cc8ab331b8cf", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRunStatus, Brief,\n ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n future::Future,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::sync::Mutex;\n\nconst AGENT_TEST_STACK_BYTES: usize = 8 * 1024 * 1024;\n\nfn run_with_agent_test_stack<F>(name: &str, future: F)\nwhere\n F: Future<Output = ()> + Send + 'static,\n{\n std::thread::Builder::new()\n .name(name.to_string())\n .stack_size(AGENT_TEST_STACK_BYTES)\n .spawn(move || {\n tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .expect(\"agent test runtime\")\n .block_on(future);\n })\n .expect(\"agent test thread\")\n .join()\n .expect(\"agent test thread panicked\");\n}\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"next-app\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"next-app\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\nfn workflow_progress_text(request: &ModelRequest) -> &str {\n request\n .messages\n .iter()\n .find(|message| {\n message.get(\"kind\").and_then(Value::as_str) == Some(\"runtime_workflow_progress\")\n })\n .and_then(|message| message.get(\"text\"))\n .and_then(Value::as_str)\n .expect(\"model request should include runtime workflow progress\")\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Next.js website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"next-app for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[test]\nfn brief_first_model_turn_preserves_user_request_without_content_sources() {\n run_with_agent_test_stack(\n \"brief-first-turn-operation-request\",\n brief_first_model_turn_preserves_user_request_without_content_sources_inner(),\n );\n}\n\nasync fn brief_first_model_turn_preserves_user_request_without_content_sources_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-brief-user-intake\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![],\n )\n .await;\n let user_request =\n \"Create an App for triaging customer incidents with owner and severity filters.\";\n let request_hash = canonical_json_hash(&json!({\n \"domain\": \"operation-user-request@1\",\n \"userRequest\": user_request,\n }));\n store\n .append_operation_user_message(\n &run.id,\n user_request,\n \"message-brief-intake-1\",\n &request_hash,\n 1,\n Some(\"when_ambiguous\"),\n Some(\"app\"),\n None,\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after first model turn\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == user_request\n }));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"next-app\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"read inputs/design-profile.json and inputs/design.md only when present\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn child_review_does_not_replay_parent_checkpoint_transcript() {\n let store = RuntimeStore::new();\n let parent = store\n .create_run(\n \"project-review-sidechain\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &parent.project_id,\n Some(&parent.id),\n \"user_message\",\n Some(\"user\"),\n \"Inspect deliberate marker REVIEW_TARGET_MARKER for zero contrast\".to_string(),\n None,\n )\n .await;\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-parent-edit-sidechain\".to_string(),\n run_id: parent.id.clone(),\n project_id: parent.project_id.clone(),\n phase: parent.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 7,\n \"text\": \"SETUP EDIT COMPLETE; DO NOT REPLAY IN REVIEW\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"parent edit transcript\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let review = store\n .create_child_run(\n &parent.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after sidechain assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&review.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].turn, 1);\n assert!(!requests[0]\n .messages\n .iter()\n .any(|message| message.to_string().contains(\"SETUP EDIT COMPLETE\")));\n let review_target = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_review_target\")\n .expect(\"Review target should be the latest scoped user message\");\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"REVIEW_TARGET_MARKER\"));\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"review.report_finding\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/app/page.tsx\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/app/page.tsx\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/app/page.tsx\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"mutation.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"mutation.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[tokio::test]\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[tokio::test]\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n semantic_context_summary: None,\n tool_catalog_snapshot: None,\n command_tasks: Vec::new(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[test]\nfn agent_loop_deterministically_compacts_history_to_workspace_context() {\n run_with_agent_test_stack(\"agent-loop-compaction\", async move {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"<!-- runtime-context:conversation-compact:\"));\n assert!(context.contains(\"## Compaction Batch\"));\n assert!(!context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"]\n == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"context_window_epoch_advanced\"\n && metadata[\"triggerReasons\"].as_array().is_some_and(|reasons| !reasons.is_empty())\n && metadata[\"conversationBytes\"].as_u64().is_some()\n && metadata[\"largestMessageBytes\"].as_u64().is_some()\n )));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n });\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/other/page.tsx\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn failed_mutation_result_is_replayed_without_reexecution() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn in_doubt_mutation_is_paused_without_reexecution() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ObserveTool), Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SequencedChunkCommitTool::default())],\n PermissionRules::default(),\n );\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/page.tsx\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn file_reads_are_observations_and_do_not_reset_no_progress() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_warn_without_rejecting_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (2, 2)]);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, metadata, .. }\n if name == \"run_observation_budget_warning\"\n && metadata.as_ref().and_then(|value| value.get(\"blocking\")).and_then(Value::as_bool) == Some(false)\n )));\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewRejectTool),\n Arc::new(ReadObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_repair_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(!third_repair_read.is_error);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(!third_read.is_error);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[1]).contains(\"diagnostic_required\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[2]).contains(\"diagnostic_required\"));\n assert!(workflow_progress_text(&requests[3]).contains(\"diagnostic_required\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_keeps_diagnostic_tools_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[3]).contains(\"preview_ready_required\"));\n assert!(workflow_progress_text(&requests[3])\n .contains(\"bounded repair mutation needs Candidate validation\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"workflow-progress-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"list-inputs\",\n \"fs.list\",\n json!({ \"path\": \"inputs\" }),\n )]),\n ModelResponse::Error(\"stop after workflow prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(ListObservationTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[0]).contains(\"discovering_inputs\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"loading_requirements\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"inputs_inventoried\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"\\\"remaining\\\":1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/app/page.tsx\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"loading_requirements\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct ListObservationTool;\n\n#[async_trait]\nimpl Tool for ListObservationTool {\n fn name(&self) -> &'static str {\n \"fs.list\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"entries\": [\"brief.md\", \"content-sources.json\"]\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"next-app\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('app/page.tsx', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/app/page.tsx\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n"}, "e229f1ea9b6b17fa557e9371dcc606cbcd219258": {"t": 1785757435.8885987, "n": 180888, "h": "2977fc1453cf9cf4", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRunStatus, Brief,\n ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n future::Future,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::sync::Mutex;\n\nconst AGENT_TEST_STACK_BYTES: usize = 8 * 1024 * 1024;\n\nfn run_with_agent_test_stack<F>(name: &str, future: F)\nwhere\n F: Future<Output = ()> + Send + 'static,\n{\n std::thread::Builder::new()\n .name(name.to_string())\n .stack_size(AGENT_TEST_STACK_BYTES)\n .spawn(move || {\n tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .expect(\"agent test runtime\")\n .block_on(future);\n })\n .expect(\"agent test thread\")\n .join()\n .expect(\"agent test thread panicked\");\n}\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"next-app\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"next-app\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\nfn workflow_progress_text(request: &ModelRequest) -> &str {\n request\n .messages\n .iter()\n .find(|message| {\n message.get(\"kind\").and_then(Value::as_str) == Some(\"runtime_workflow_progress\")\n })\n .and_then(|message| message.get(\"text\"))\n .and_then(Value::as_str)\n .expect(\"model request should include runtime workflow progress\")\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Next.js website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"next-app for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[test]\nfn brief_first_model_turn_preserves_user_request_without_content_sources() {\n run_with_agent_test_stack(\n \"brief-first-turn-operation-request\",\n brief_first_model_turn_preserves_user_request_without_content_sources_inner(),\n );\n}\n\nasync fn brief_first_model_turn_preserves_user_request_without_content_sources_inner() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-brief-user-intake\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![],\n )\n .await;\n let user_request =\n \"Create an App for triaging customer incidents with owner and severity filters.\";\n let request_hash = canonical_json_hash(&json!({\n \"domain\": \"operation-user-request@1\",\n \"userRequest\": user_request,\n }));\n store\n .append_operation_user_message(\n &run.id,\n user_request,\n \"message-brief-intake-1\",\n &request_hash,\n 1,\n Some(\"when_ambiguous\"),\n Some(\"app\"),\n None,\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after first model turn\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == user_request\n }));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"next-app\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"read inputs/design-profile.json and inputs/design.md only when present\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn child_review_does_not_replay_parent_checkpoint_transcript() {\n let store = RuntimeStore::new();\n let parent = store\n .create_run(\n \"project-review-sidechain\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &parent.project_id,\n Some(&parent.id),\n \"user_message\",\n Some(\"user\"),\n \"Inspect deliberate marker REVIEW_TARGET_MARKER for zero contrast\".to_string(),\n None,\n )\n .await;\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-parent-edit-sidechain\".to_string(),\n run_id: parent.id.clone(),\n project_id: parent.project_id.clone(),\n phase: parent.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 7,\n \"text\": \"SETUP EDIT COMPLETE; DO NOT REPLAY IN REVIEW\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"parent edit transcript\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let review = store\n .create_child_run(\n &parent.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after sidechain assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&review.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].turn, 1);\n assert!(!requests[0]\n .messages\n .iter()\n .any(|message| message.to_string().contains(\"SETUP EDIT COMPLETE\")));\n let review_target = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_review_target\")\n .expect(\"Review target should be the latest scoped user message\");\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"REVIEW_TARGET_MARKER\"));\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"review.report_finding\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/app/page.tsx\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/app/page.tsx\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/app/page.tsx\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"mutation.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"mutation.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[tokio::test]\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[tokio::test]\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[test]\nfn agent_loop_deterministically_compacts_history_to_workspace_context() {\n run_with_agent_test_stack(\"agent-loop-compaction\", async move {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"<!-- runtime-context:conversation-compact:\"));\n assert!(context.contains(\"## Compaction Batch\"));\n assert!(!context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"]\n == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"context_window_epoch_advanced\"\n && metadata[\"triggerReasons\"].as_array().is_some_and(|reasons| !reasons.is_empty())\n && metadata[\"conversationBytes\"].as_u64().is_some()\n && metadata[\"largestMessageBytes\"].as_u64().is_some()\n )));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n });\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/other/page.tsx\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn failed_mutation_result_is_replayed_without_reexecution() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn in_doubt_mutation_is_paused_without_reexecution() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ObserveTool), Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SequencedChunkCommitTool::default())],\n PermissionRules::default(),\n );\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/page.tsx\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn file_reads_are_observations_and_do_not_reset_no_progress() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_warn_without_rejecting_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (2, 2)]);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, metadata, .. }\n if name == \"run_observation_budget_warning\"\n && metadata.as_ref().and_then(|value| value.get(\"blocking\")).and_then(Value::as_bool) == Some(false)\n )));\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewRejectTool),\n Arc::new(ReadObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_repair_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(!third_repair_read.is_error);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(!third_read.is_error);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[1]).contains(\"diagnostic_required\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[2]).contains(\"diagnostic_required\"));\n assert!(workflow_progress_text(&requests[3]).contains(\"diagnostic_required\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_keeps_diagnostic_tools_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[3]).contains(\"preview_ready_required\"));\n assert!(workflow_progress_text(&requests[3])\n .contains(\"bounded repair mutation needs Candidate validation\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"workflow-progress-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"list-inputs\",\n \"fs.list\",\n json!({ \"path\": \"inputs\" }),\n )]),\n ModelResponse::Error(\"stop after workflow prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(ListObservationTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[0]).contains(\"discovering_inputs\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"loading_requirements\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"inputs_inventoried\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"\\\"remaining\\\":1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/app/page.tsx\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"loading_requirements\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct ListObservationTool;\n\n#[async_trait]\nimpl Tool for ListObservationTool {\n fn name(&self) -> &'static str {\n \"fs.list\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"entries\": [\"brief.md\", \"content-sources.json\"]\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"next-app\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('app/page.tsx', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/app/page.tsx\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n"}, "c5e4c7b59e8673900e1e6cbe20eb2c2e35e78cc1": {"t": 1785757438.3052583, "n": 179067, "h": "99687e7943688fcf", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRunStatus, Brief,\n ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n future::Future,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::sync::Mutex;\n\nconst AGENT_TEST_STACK_BYTES: usize = 8 * 1024 * 1024;\n\nfn run_with_agent_test_stack<F>(name: &str, future: F)\nwhere\n F: Future<Output = ()> + Send + 'static,\n{\n std::thread::Builder::new()\n .name(name.to_string())\n .stack_size(AGENT_TEST_STACK_BYTES)\n .spawn(move || {\n tokio::runtime::Builder::new_current_thread()\n .enable_all()\n .build()\n .expect(\"agent test runtime\")\n .block_on(future);\n })\n .expect(\"agent test thread\")\n .join()\n .expect(\"agent test thread panicked\");\n}\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"next-app\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"next-app\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\nfn workflow_progress_text(request: &ModelRequest) -> &str {\n request\n .messages\n .iter()\n .find(|message| {\n message.get(\"kind\").and_then(Value::as_str) == Some(\"runtime_workflow_progress\")\n })\n .and_then(|message| message.get(\"text\"))\n .and_then(Value::as_str)\n .expect(\"model request should include runtime workflow progress\")\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Next.js website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"next-app for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"next-app\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"read inputs/design-profile.json and inputs/design.md only when present\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn child_review_does_not_replay_parent_checkpoint_transcript() {\n let store = RuntimeStore::new();\n let parent = store\n .create_run(\n \"project-review-sidechain\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &parent.project_id,\n Some(&parent.id),\n \"user_message\",\n Some(\"user\"),\n \"Inspect deliberate marker REVIEW_TARGET_MARKER for zero contrast\".to_string(),\n None,\n )\n .await;\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-parent-edit-sidechain\".to_string(),\n run_id: parent.id.clone(),\n project_id: parent.project_id.clone(),\n phase: parent.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 7,\n \"text\": \"SETUP EDIT COMPLETE; DO NOT REPLAY IN REVIEW\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"parent edit transcript\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let review = store\n .create_child_run(\n &parent.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after sidechain assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&review.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].turn, 1);\n assert!(!requests[0]\n .messages\n .iter()\n .any(|message| message.to_string().contains(\"SETUP EDIT COMPLETE\")));\n let review_target = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_review_target\")\n .expect(\"Review target should be the latest scoped user message\");\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"REVIEW_TARGET_MARKER\"));\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"review.report_finding\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/app/page.tsx\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/app/page.tsx\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/app/page.tsx\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"mutation.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"mutation.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[tokio::test]\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[tokio::test]\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[test]\nfn agent_loop_deterministically_compacts_history_to_workspace_context() {\n run_with_agent_test_stack(\"agent-loop-compaction\", async move {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"<!-- runtime-context:conversation-compact:\"));\n assert!(context.contains(\"## Compaction Batch\"));\n assert!(!context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"]\n == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"context_window_epoch_advanced\"\n && metadata[\"triggerReasons\"].as_array().is_some_and(|reasons| !reasons.is_empty())\n && metadata[\"conversationBytes\"].as_u64().is_some()\n && metadata[\"largestMessageBytes\"].as_u64().is_some()\n )));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n });\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/other/page.tsx\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn failed_mutation_result_is_replayed_without_reexecution() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn in_doubt_mutation_is_paused_without_reexecution() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ObserveTool), Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SequencedChunkCommitTool::default())],\n PermissionRules::default(),\n );\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/page.tsx\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn file_reads_are_observations_and_do_not_reset_no_progress() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_warn_without_rejecting_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (2, 2)]);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, metadata, .. }\n if name == \"run_observation_budget_warning\"\n && metadata.as_ref().and_then(|value| value.get(\"blocking\")).and_then(Value::as_bool) == Some(false)\n )));\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewRejectTool),\n Arc::new(ReadObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_repair_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(!third_repair_read.is_error);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(!third_read.is_error);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[1]).contains(\"diagnostic_required\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[2]).contains(\"diagnostic_required\"));\n assert!(workflow_progress_text(&requests[3]).contains(\"diagnostic_required\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_keeps_diagnostic_tools_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(workflow_progress_text(&requests[3]).contains(\"preview_ready_required\"));\n assert!(workflow_progress_text(&requests[3])\n .contains(\"bounded repair mutation needs Candidate validation\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"workflow-progress-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"list-inputs\",\n \"fs.list\",\n json!({ \"path\": \"inputs\" }),\n )]),\n ModelResponse::Error(\"stop after workflow prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(ListObservationTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(workflow_progress_text(&requests[0]).contains(\"discovering_inputs\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"loading_requirements\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"inputs_inventoried\"));\n assert!(workflow_progress_text(&requests[1]).contains(\"\\\"remaining\\\":1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/app/page.tsx\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"loading_requirements\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct ListObservationTool;\n\n#[async_trait]\nimpl Tool for ListObservationTool {\n fn name(&self) -> &'static str {\n \"fs.list\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"entries\": [\"brief.md\", \"content-sources.json\"]\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"next-app\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('app/page.tsx', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/app/page.tsx\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n"}, "613d7b3f77072db688b217ec18e6aee2690bcb19": {"t": 1785757440.133052, "n": 177053, "h": "c0697a385b74807d", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRunStatus, Brief,\n ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::sync::Mutex;\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"next-app\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"next-app\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Next.js website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"next-app for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"next-app\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"read inputs/design-profile.json and inputs/design.md only when present\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn child_review_does_not_replay_parent_checkpoint_transcript() {\n let store = RuntimeStore::new();\n let parent = store\n .create_run(\n \"project-review-sidechain\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &parent.project_id,\n Some(&parent.id),\n \"user_message\",\n Some(\"user\"),\n \"Inspect deliberate marker REVIEW_TARGET_MARKER for zero contrast\".to_string(),\n None,\n )\n .await;\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-parent-edit-sidechain\".to_string(),\n run_id: parent.id.clone(),\n project_id: parent.project_id.clone(),\n phase: parent.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 7,\n \"text\": \"SETUP EDIT COMPLETE; DO NOT REPLAY IN REVIEW\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"parent edit transcript\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let review = store\n .create_child_run(\n &parent.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after sidechain assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&review.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].turn, 1);\n assert!(!requests[0]\n .messages\n .iter()\n .any(|message| message.to_string().contains(\"SETUP EDIT COMPLETE\")));\n let review_target = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_review_target\")\n .expect(\"Review target should be the latest scoped user message\");\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"REVIEW_TARGET_MARKER\"));\n assert!(review_target[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"review.report_finding\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/app/page.tsx\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/app/page.tsx\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/app/page.tsx\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"mutation.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"mutation.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[tokio::test]\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[tokio::test]\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n context_content_hash: None,\n run_context_binding_hash: None,\n runtime_attestation_hash: None,\n context_window_epoch: None,\n execution_profile: None,\n target_session_epoch: None,\n target_workspace_revision: None,\n workflow_state: None,\n observation_receipts_version: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"<!-- runtime-context:conversation-compact:\"));\n assert!(context.contains(\"## Compaction Batch\"));\n assert!(!context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"] == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"next-app\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/other/page.tsx\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn failed_mutation_result_is_replayed_without_reexecution() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn in_doubt_mutation_is_paused_without_reexecution() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/app/page.tsx\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ObserveTool), Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SequencedChunkCommitTool::default())],\n PermissionRules::default(),\n );\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/app/page.tsx\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/app/page.tsx\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn first_unique_file_reads_count_as_bounded_progress_but_repeats_do_not() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_warn_without_rejecting_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (2, 2)]);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, metadata, .. }\n if name == \"run_observation_budget_warning\"\n && metadata.as_ref().and_then(|value| value.get(\"blocking\")).and_then(Value::as_bool) == Some(false)\n )));\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewRejectTool),\n Arc::new(ReadObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_repair_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(!third_repair_read.is_error);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(!third_read.is_error);\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 3,\n ..\n }\n )));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. } if name == \"run_observation_budget_warning\"\n )));\n}\n\n#[tokio::test]\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(requests[1].system_prompt.contains(\"diagnostic_required\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[2].system_prompt.contains(\"diagnostic_required\"));\n assert!(requests[3].system_prompt.contains(\"diagnostic_required\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_keeps_diagnostic_tools_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.read\"));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3].system_prompt.contains(\"preview_ready_required\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"bounded repair mutation needs Candidate validation\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"workflow-progress-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"list-inputs\",\n \"fs.list\",\n json!({ \"path\": \"inputs\" }),\n )]),\n ModelResponse::Error(\"stop after workflow prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(ListObservationTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(requests[0].system_prompt.contains(\"discovering_inputs\"));\n assert!(requests[1].system_prompt.contains(\"loading_requirements\"));\n assert!(requests[1].system_prompt.contains(\"inputs_inventoried\"));\n assert!(requests[1].system_prompt.contains(\"\\\"remaining\\\":1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/app/page.tsx\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"loading_requirements\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct ListObservationTool;\n\n#[async_trait]\nimpl Tool for ListObservationTool {\n fn name(&self) -> &'static str {\n \"fs.list\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"entries\": [\"brief.md\", \"content-sources.json\"]\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"next-app\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('app/page.tsx', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/app/page.tsx\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n"}, "19801dc0c50fc785bd234e943b30db4ed85df952": {"t": 1785757442.1801016, "n": 184128, "h": "0a8fd086639ed64a", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n config::RuntimePolicyProfile,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRunStatus, Brief,\n ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"astro-website\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"astro-website\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"astro-website\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Read inputs/design-profile.json and inputs/design.md\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"patch.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"patch.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[tokio::test]\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[tokio::test]\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"] == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor()\n .with_policy_profile_and_registry(\n RuntimePolicyProfile::LocalE2e,\n \"https://registry.internal.example/npm/\",\n )\n .with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"tool-driven build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolCompleted { tool, metadata, .. }\n if tool == \"project.build\"\n && metadata.as_ref().is_some_and(|metadata| {\n metadata\n .get(\"postToolUseSuccess\")\n .and_then(|hook| hook.get(\"effect\"))\n .and_then(Value::as_str)\n == Some(\"build_state_updated\")\n })\n )\n }));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor()\n .with_policy_profile_and_registry(\n RuntimePolicyProfile::LocalE2e,\n \"https://registry.internal.example/npm/\",\n )\n .with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"chunked build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/src/pages/index.astro\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/src/pages/other.astro\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn failed_mutation_result_is_replayed_without_reexecution() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/src/pages/index.astro\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn in_doubt_mutation_is_paused_without_reexecution() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/src/pages/index.astro\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ObserveTool), Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SequencedChunkCommitTool::default())],\n PermissionRules::default(),\n );\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/src/pages/index.astro\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn first_unique_file_reads_count_as_bounded_progress_but_repeats_do_not() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_reject_excess_reads_and_searches_but_allow_mutation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let rejected = results\n .iter()\n .filter(|result| {\n result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str)\n == Some(\"run.observation_budget_exhausted\")\n })\n .collect::<Vec<_>>();\n assert_eq!(rejected.len(), 2);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (1, 1)]);\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewRejectTool),\n Arc::new(ReadObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let repair_rejection = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(repair_rejection.is_error);\n assert_eq!(\n repair_rejection.metadata.as_ref().unwrap()[\"category\"],\n \"repair_read\"\n );\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(third_read.is_error);\n assert_eq!(\n third_read.metadata.as_ref().unwrap()[\"category\"],\n \"repair_read\"\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn missing_dependency_publish_failure_enters_repair_before_no_progress_stop() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"missing-dependency-repair-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-missing-dependency\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after repair prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(PreviewMissingDependencyTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_no_progress_turns: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(requests[1].system_prompt.contains(\"repairing_source\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[2].system_prompt.contains(\"repairing_source\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"The repair observation budget is exhausted\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"Do not call fs.read, fs.list, or fs.search\"));\n assert!(requests[3]\n .tools\n .iter()\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\" | \"fs.search\")));\n assert!(requests[3]\n .deferred_tools\n .iter()\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\" | \"fs.search\")));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_hides_reads_while_search_remains_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\")));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3].system_prompt.contains(\"publishing_repair\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"Call preview.publish now; do not read, list, search\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"workflow-progress-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"list-inputs\",\n \"fs.list\",\n json!({ \"path\": \"inputs\" }),\n )]),\n ModelResponse::Error(\"stop after workflow prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(ListObservationTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(requests[0].system_prompt.contains(\"discovering_inputs\"));\n assert!(requests[1].system_prompt.contains(\"loading_requirements\"));\n assert!(requests[1].system_prompt.contains(\"inputs_inventoried\"));\n assert!(requests[1].system_prompt.contains(\"\\\"remaining\\\":1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/src/pages/index.astro\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"loading_requirements\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct PreviewMissingDependencyTool;\n\n#[async_trait]\nimpl Tool for PreviewMissingDependencyTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"missing dependency\",\n \"build.missing_dependency\",\n json!({ \"stderr\": \"module not found\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct ListObservationTool;\n\n#[async_trait]\nimpl Tool for ListObservationTool {\n fn name(&self) -> &'static str {\n \"fs.list\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"entries\": [\"brief.md\", \"content-sources.json\"]\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "ac9613817729237b915759338d28ae9dff3988b2": {"t": 1785757444.5422502, "n": 181642, "h": "253a2ab1212aec83", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n config::RuntimePolicyProfile,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor,\n ToolResult, ValidationError,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n canonical_json_hash, AgentCheckpoint, AgentEvent, AgentPhase, AgentRunStatus, Brief,\n ContentSource, DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"astro-website\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"astro-website\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"astro-website\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Read inputs/design-profile.json and inputs/design.md\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"patch.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"patch.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_preserves_distinct_user_messages_with_identical_text() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"duplicate-message-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n for _ in 0..2 {\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"继续\",\n None,\n )\n .await;\n }\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"duplicate-message-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Duplicate messages preserved\" }),\n )])],\n captured_requests.clone(),\n );\n\n AgentLoop::new(store, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n let duplicate_count = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"role\"] == \"user\" && message[\"text\"] == \"继续\")\n .count();\n assert_eq!(duplicate_count, 2);\n}\n\n#[tokio::test]\nasync fn agent_loop_merges_persisted_conversation_before_appending_after_restart() {\n let storage = unique_temp_dir(\"agent-loop-conversation-restart\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"conversation-restart-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message consumed before restart\",\n None,\n )\n .await;\n let consumed = store\n .conversation_items(&run.project_id)\n .await\n .into_iter()\n .find(|item| item.text == \"message consumed before restart\")\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"conversation-restart-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"user\",\n \"kind\": \"user_message\",\n \"conversationItemId\": consumed.id,\n \"text\": consumed.text,\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"conversation cursor before restart\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n reopened\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"message appended after restart\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"message received\".to_string())],\n captured_requests.clone(),\n );\n\n AgentLoop::new(reopened, Arc::new(model))\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n assert!(requests.iter().any(|request| request\n .messages\n .iter()\n .any(|message| message[\"text\"] == \"message appended after restart\")));\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn approved_permission_replaces_prior_tool_error_before_model_resume() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"approved-value\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.approved_resume\",\n Some(\"approved-resume-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"approved-resume-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"approved-resume-tool-use\",\n \"toolName\": \"test.approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let calls = Arc::new(ApprovedResumeTool::default());\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\"approval observed\".to_string())],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(vec![calls.clone()], PermissionRules::default());\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert_eq!(calls.calls.load(Ordering::SeqCst), 1);\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"approved-resume-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], false);\n assert_eq!(tool_results[0][\"content\"][\"approved\"], \"approved-value\");\n}\n\n#[tokio::test]\nasync fn rejected_approved_input_is_consumed_and_reported_once_to_model() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"rejected-approved-resume-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let input = json!({ \"value\": \"invalid\" });\n let permission = store\n .create_tool_permission_request(\n &run.project_id,\n &run.id,\n \"test.rejecting_approved_resume\",\n Some(\"rejected-approved-tool-use\"),\n Some(input.clone()),\n )\n .await;\n store\n .resolve_permission(&permission.id, \"allow\")\n .await\n .unwrap();\n store\n .update_run_status(&run.id, AgentRunStatus::Running)\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"rejected-approved-checkpoint\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![\n json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"\",\n \"toolCalls\": [{\n \"id\": \"rejected-approved-tool-use\",\n \"name\": \"test.rejecting_approved_resume\",\n \"input\": input,\n }],\n }),\n json!({\n \"role\": \"tool\",\n \"turn\": 1,\n \"toolUseId\": \"rejected-approved-tool-use\",\n \"toolName\": \"test.rejecting_approved_resume\",\n \"isError\": true,\n \"content\": { \"error\": \"approval required\" },\n }),\n ],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"waiting for approval\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::TextOnly(\n \"validation error observed\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(RejectingApprovedResumeTool)],\n PermissionRules::default(),\n );\n\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(store\n .pending_permission(&permission.id)\n .await\n .unwrap()\n .consumed_at\n .is_some());\n let requests = captured_requests.lock().await;\n assert!(!requests.is_empty());\n let tool_results = requests[0]\n .messages\n .iter()\n .filter(|message| message[\"toolUseId\"] == \"rejected-approved-tool-use\")\n .collect::<Vec<_>>();\n assert_eq!(tool_results.len(), 1);\n assert_eq!(tool_results[0][\"isError\"], true);\n assert!(tool_results[0][\"content\"][\"error\"]\n .as_str()\n .unwrap()\n .contains(\"approved input is invalid\"));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n &run.project_id,\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Original instruction must not be resurrected after compaction\",\n None,\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(RecordingModelClient::new(\n responses,\n captured_requests.clone(),\n )),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let requests = captured_requests.lock().await;\n let instruction_presence = requests\n .iter()\n .map(|request| {\n request.messages.iter().any(|message| {\n message[\"text\"] == \"Original instruction must not be resurrected after compaction\"\n })\n })\n .collect::<Vec<_>>();\n let first_absent = instruction_presence\n .iter()\n .position(|present| !present)\n .expect(\"old user instruction should eventually compact out of the active window\");\n assert!(instruction_presence[first_absent..]\n .iter()\n .all(|present| !present));\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor()\n .with_policy_profile_and_registry(\n RuntimePolicyProfile::LocalE2e,\n \"https://registry.internal.example/npm/\",\n )\n .with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"tool-driven build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolCompleted { tool, metadata, .. }\n if tool == \"project.build\"\n && metadata.as_ref().is_some_and(|metadata| {\n metadata\n .get(\"postToolUseSuccess\")\n .and_then(|hook| hook.get(\"effect\"))\n .and_then(Value::as_str)\n == Some(\"build_state_updated\")\n })\n )\n }));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor()\n .with_policy_profile_and_registry(\n RuntimePolicyProfile::LocalE2e,\n \"https://registry.internal.example/npm/\",\n )\n .with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"chunked build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn tool_execution_result_is_replayed_durably_and_identity_conflicts_fail_closed() {\n let storage = unique_temp_dir(\"tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/src/pages/index.astro\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input.clone(),\n )\n .await;\n assert!(!first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let mut ledger = std::fs::OpenOptions::new()\n .append(true)\n .open(storage.join(\"tool-executions.jsonl\"))\n .unwrap();\n std::io::Write::write_all(&mut ledger, b\"{\\\"truncated\\\"\").unwrap();\n drop(ledger);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n assert!(!replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"durableProjection\"],\n true\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n assert!(fs::read(storage.join(\"tool-executions.jsonl\"))\n .unwrap()\n .ends_with(b\"\\n\"));\n\n let conflict = executor\n .execute(\n reopened.clone(),\n &run.id,\n \"durable-tool-use-1\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/src/pages/other.astro\" }),\n )\n .await;\n assert!(conflict.result.is_error);\n assert_eq!(\n conflict.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_identity_conflict\"\n );\n assert_eq!(\n reopened.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Failed\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn failed_mutation_result_is_replayed_without_reexecution() {\n let storage = unique_temp_dir(\"failed-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"failed-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(FailedMutationTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/src/pages/index.astro\" });\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input.clone(),\n )\n .await;\n assert!(first.result.is_error);\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n drop(store);\n\n let reopened = RuntimeStore::with_checkpoint_dir(&storage);\n let replay = executor\n .execute(\n reopened,\n &run.id,\n \"failed-mutation-tool-use\",\n \"test.failed_mutation\",\n input,\n )\n .await;\n\n assert!(replay.result.is_error);\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert_eq!(\n replay.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"test.mutation_failed\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 1);\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn tool_execution_ledger_omits_raw_output_and_uses_private_permissions() {\n let storage = unique_temp_dir(\"secret-tool-execution-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"secret-tool-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SecretOutputMutationTool)],\n PermissionRules::default(),\n );\n\n let first = executor\n .execute(\n store.clone(),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!first.result.is_error);\n let ledger_path = store.tool_execution_log_path();\n let ledger = fs::read_to_string(&ledger_path).unwrap();\n assert!(!ledger.contains(\"secret-sentinel\"));\n assert!(!ledger.contains(\"abcdefghijklmnop\"));\n assert!(ledger.contains(\"resultDigest\"));\n assert!(ledger.contains(\"original output was omitted\"));\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n assert_eq!(\n fs::metadata(&ledger_path).unwrap().permissions().mode() & 0o777,\n 0o600\n );\n }\n drop(store);\n\n let replay = executor\n .execute(\n RuntimeStore::with_checkpoint_dir(&storage),\n &run.id,\n \"secret-output-tool-use\",\n \"test.secret_output_mutation\",\n json!({}),\n )\n .await;\n assert!(!replay\n .result\n .content\n .to_string()\n .contains(\"secret-sentinel\"));\n assert_eq!(replay.result.content[\"replayed\"], true);\n assert!(replay.result.content[\"resultDigest\"].is_string());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn read_only_tool_results_are_not_persisted_in_execution_ledger() {\n let storage = unique_temp_dir(\"read-only-tool-ledger\");\n fs::create_dir_all(storage.join(\"project\")).unwrap();\n fs::write(storage.join(\"project/source.txt\"), \"private source text\").unwrap();\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"read-only-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &storage,\n );\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"read-tool-use-1\",\n \"fs.read\",\n json!({ \"path\": \"project/source.txt\" }),\n )\n .await;\n\n assert!(!result.result.is_error);\n assert!(!store.tool_execution_log_path().exists());\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn in_doubt_mutation_is_paused_without_reexecution() {\n let storage = unique_temp_dir(\"in-doubt-tool-ledger\");\n let store = RuntimeStore::with_checkpoint_dir(&storage);\n let run = store\n .create_run(\n \"in-doubt-ledger-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let tool = Arc::new(SequencedChunkCommitTool::default());\n let executor = ToolExecutor::new(vec![tool.clone()], PermissionRules::default());\n let input = json!({ \"path\": \"project/src/pages/index.astro\" });\n let input_hash = canonical_json_hash(&json!({\n \"tool\": \"fs.commit_chunks\",\n \"input\": input.clone(),\n }));\n store\n .reserve_tool_execution(\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n &input_hash,\n )\n .await\n .unwrap();\n\n let result = executor\n .execute(\n store.clone(),\n &run.id,\n \"in-doubt-tool-use-1\",\n \"fs.commit_chunks\",\n input,\n )\n .await;\n\n assert!(result.result.is_error);\n assert_eq!(\n result.result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.execution_in_doubt\"\n );\n assert_eq!(tool.calls.load(Ordering::SeqCst), 0);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::NeedsUserInput\n );\n fs::remove_dir_all(storage).unwrap();\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ObserveTool), Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SequencedChunkCommitTool::default())],\n PermissionRules::default(),\n );\n let repeated_commit = |tool_use_id| {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n tool_use_id,\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/src/pages/index.astro\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(\"commit-repair-1\"),\n repeated_commit(\"commit-repair-2\"),\n repeated_commit(\"commit-repair-3\"),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn first_unique_file_reads_count_as_bounded_progress_but_repeats_do_not() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_reject_excess_reads_and_searches_but_allow_mutation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let rejected = results\n .iter()\n .filter(|result| {\n result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str)\n == Some(\"run.observation_budget_exhausted\")\n })\n .collect::<Vec<_>>();\n assert_eq!(rejected.len(), 2);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (1, 1)]);\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewRejectTool),\n Arc::new(ReadObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let repair_rejection = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(repair_rejection.is_error);\n assert_eq!(\n repair_rejection.metadata.as_ref().unwrap()[\"category\"],\n \"repair_read\"\n );\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(third_read.is_error);\n assert_eq!(\n third_read.metadata.as_ref().unwrap()[\"category\"],\n \"repair_read\"\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[2].system_prompt.contains(\"repairing_source\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"The repair observation budget is exhausted\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"Do not call fs.read, fs.list, or fs.search\"));\n assert!(requests[3]\n .tools\n .iter()\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\" | \"fs.search\")));\n assert!(requests[3]\n .deferred_tools\n .iter()\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\" | \"fs.search\")));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_hides_reads_while_search_remains_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\")));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3].system_prompt.contains(\"publishing_repair\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"Call preview.publish now; do not read, list, search\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"workflow-progress-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"list-inputs\",\n \"fs.list\",\n json!({ \"path\": \"inputs\" }),\n )]),\n ModelResponse::Error(\"stop after workflow prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(ListObservationTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(requests[0].system_prompt.contains(\"discovering_inputs\"));\n assert!(requests[1].system_prompt.contains(\"loading_requirements\"));\n assert!(requests[1].system_prompt.contains(\"inputs_inventoried\"));\n assert!(requests[1].system_prompt.contains(\"\\\"remaining\\\":1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/src/pages/index.astro\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"loading_requirements\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\n#[derive(Default)]\nstruct ApprovedResumeTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for ApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Ok(ToolResult::ok(json!({ \"approved\": input[\"value\"] })))\n }\n}\n\nstruct RejectingApprovedResumeTool;\n\n#[async_trait]\nimpl Tool for RejectingApprovedResumeTool {\n fn name(&self) -> &'static str {\n \"test.rejecting_approved_resume\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn validate_input(\n &self,\n _input: Value,\n _ctx: &ToolContext,\n ) -> Result<Value, ValidationError> {\n Err(ValidationError::with_kind(\n \"approved input is invalid\",\n \"tool.input_schema_invalid\",\n ))\n }\n\n async fn check_permission(&self, _input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Ask {\n message: \"approval required\".to_string(),\n reason: PermissionReason::Other {\n reason: \"test approval\".to_string(),\n },\n suggestions: None,\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n panic!(\"validation failure must prevent tool execution\")\n }\n}\n\n#[derive(Default)]\nstruct FailedMutationTool {\n calls: AtomicUsize,\n}\n\n#[async_trait]\nimpl Tool for FailedMutationTool {\n fn name(&self) -> &'static str {\n \"test.failed_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n self.calls.fetch_add(1, Ordering::SeqCst);\n Err(ToolError::typed_recoverable(\n \"mutation failed after side effect\",\n \"test.mutation_failed\",\n json!({ \"stage\": \"after_side_effect\" }),\n ))\n }\n}\n\nstruct SecretOutputMutationTool;\n\n#[async_trait]\nimpl Tool for SecretOutputMutationTool {\n fn name(&self) -> &'static str {\n \"test.secret_output_mutation\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"stdout\": concat!(\n \"API_KEY=ship-secret-sentinel Bearer abcdefghijklmnop\\n\",\n \"AWS_SECRET_ACCESS_KEY=aws-secret-sentinel\\n\",\n \"DATABASE_URL=postgres://user:database-secret-sentinel@host/db\\n\",\n \"JWT=eyJhbGciOiJIUzI1NiJ9.jwt-secret-sentinel.signature\\n\",\n \"-----BEGIN PRIVATE KEY-----\\npem-secret-sentinel\\n-----END PRIVATE KEY-----\"\n ),\n \"apiKey\": \"nested-secret-sentinel\",\n })))\n }\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct ListObservationTool;\n\n#[async_trait]\nimpl Tool for ListObservationTool {\n fn name(&self) -> &'static str {\n \"fs.list\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"entries\": [\"brief.md\", \"content-sources.json\"]\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "54f966925dcebdcc2ad592d6a7fa65acb5909750": {"t": 1785757446.9763303, "n": 157066, "h": "73a14d2c59eb1c05", "c": "use anydesign_runtime::{\n agent_loop::{AgentLoop, AgentLoopLimits},\n config::RuntimePolicyProfile,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelClientTurn, ModelGatewayScope, ModelRequest,\n ModelResponse, ModelTokenUsage, OpenAiCompatibleModelClient, ToolCall,\n ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor, ToolResult,\n },\n streaming::StreamingToolExecutor,\n },\n types::{\n AgentCheckpoint, AgentEvent, AgentPhase, AgentRunStatus, Brief, ContentSource,\n DesignProfile, ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{\n collections::VecDeque,\n fs,\n path::PathBuf,\n sync::{\n atomic::{AtomicUsize, Ordering},\n Arc,\n },\n time::Duration,\n};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"astro-website\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"astro-website\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"astro-website\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Read inputs/design-profile.json and inputs/design.md\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn tool_policy_recovery_text_is_reprompted_and_does_not_trip_empty_turn_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"tool-policy-recovery-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let recovery = \"[runtime_tool_policy_recovery] unavailable observation tool\".to_string();\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery.clone()),\n ModelResponse::TextOnly(recovery),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-source\" && !result.is_error));\n assert!(captured_requests.lock().await[1]\n .messages\n .iter()\n .any(|message| message.get(\"kind\").and_then(Value::as_str)\n == Some(\"runtime_tool_policy_recovery\")));\n assert!(!store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary == \"No tool calls for 3 consecutive turns\"\n )));\n}\n\n#[tokio::test]\nasync fn model_turn_budget_stops_the_run_before_unbounded_iteration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-turn-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"turn one\".to_string()),\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelResponse::TextOnly(\"must not be requested\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 2,\n max_tool_calls: 60,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"model-turn budget\") && summary.contains(\"limit=2\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn tool_call_budget_preserves_tool_result_pairing_and_stops_execution() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-tool-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"budget-tool-1\", \"not.registered\", json!({})),\n ToolCall::new(\"budget-tool-2\", \"not.registered\", json!({})),\n ])]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_turns: 20,\n max_tool_calls: 1,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert!(results.iter().all(|result| result.is_error));\n assert!(results.iter().all(|result| {\n result.content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"tool-call budget exhausted\"))\n }));\n assert_eq!(\n store\n .events(&run.id)\n .await\n .iter()\n .filter(|event| matches!(event, AgentEvent::ToolStarted { .. }))\n .count(),\n 2\n );\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn input_token_budget_stops_before_tool_execution_and_preserves_pairing() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-input-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"token-budget-tool\",\n \"not.registered\",\n json!({}),\n )]),\n ModelTokenUsage {\n input_tokens: 11,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"token-budget-tool\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .is_some_and(|error| error.contains(\"token budget exhausted\")));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelUsage {\n turn: 1,\n input_tokens: 11,\n output_tokens: 1,\n estimated: false,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn output_token_budget_stops_text_response_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-output-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"large response\".to_string()),\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 11,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 100,\n max_output_tokens: 10,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"output_used=11\") && summary.contains(\"output_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn recovered_token_usage_is_counted_once_per_model_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-token-budget\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 8,\n output_tokens: 2,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-token-budget\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"assistant\",\n \"turn\": 1,\n \"text\": \"persisted turn\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted token usage\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::TextOnly(\"turn two\".to_string()),\n ModelTokenUsage {\n input_tokens: 5,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_input_tokens: 10,\n max_output_tokens: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"input_used=13\") && summary.contains(\"input_limit=10\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn mixed_recoverable_protocol_errors_open_the_shared_fuse() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = UsageModelClient::new(vec![\n (\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"protocol-parse\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 120,\n raw_sha256: \"parse-hash\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n (\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n ),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ModelProtocolError { .. }))\n .count(),\n 2\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::RunCompleted { summary, .. }\n if summary.contains(\"protocol error fuse opened\")\n )\n }));\n}\n\n#[tokio::test]\nasync fn protocol_error_fuse_restores_consecutive_count_after_restart() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-recovered-protocol-fuse\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::ModelUsage {\n run_id: run.id.clone(),\n turn: 1,\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n estimated: false,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .append_event(AgentEvent::ModelProtocolError {\n run_id: run.id.clone(),\n turn: 1,\n kind: \"tool_input_json_parse_failed\".to_string(),\n consecutive: 1,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n store\n .save_checkpoint(AgentCheckpoint {\n id: \"checkpoint-protocol-fuse\".to_string(),\n run_id: run.id.clone(),\n project_id: run.project_id.clone(),\n phase: run.phase,\n message_window: vec![json!({\n \"role\": \"system\",\n \"turn\": 1,\n \"text\": \"recover after protocol error\",\n })],\n conversation_range: None,\n task_list: vec![],\n workspace_snapshot_uri: None,\n build_result: None,\n brief_version: None,\n design_version: None,\n last_known_preview_url: None,\n context_summary: \"persisted protocol error\".to_string(),\n created_at: Utc::now(),\n })\n .await\n .unwrap();\n let model = UsageModelClient::new(vec![(\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"protocol-recovered-large\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 100_000,\n max_input_chars: 96_000,\n raw_sha256: \"recovered-large-hash\".to_string(),\n }],\n },\n ModelTokenUsage {\n input_tokens: 1,\n output_tokens: 1,\n cached_input_tokens: 0,\n },\n )]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model)).with_limits(AgentLoopLimits {\n max_consecutive_protocol_errors: 2,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::ModelProtocolError {\n turn: 2,\n consecutive: 2,\n ..\n }\n )));\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"patch.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"patch.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(responses)),\n executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 50,\n max_tool_calls: 50,\n max_input_tokens: 10_000_000,\n max_output_tokens: 1_000_000,\n max_no_progress_turns: 100,\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n let completed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::RunCompleted {\n status, summary, ..\n } => Some((status, summary)),\n _ => None,\n })\n .expect(\"terminal tool failure should close the event stream\");\n assert_eq!(completed.0, \"failed\");\n assert!(completed.1.contains(\"sandbox channel disconnected\"));\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor()\n .with_policy_profile_and_registry(\n RuntimePolicyProfile::LocalE2e,\n \"https://registry.internal.example/npm/\",\n )\n .with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"tool-driven build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolCompleted { tool, metadata, .. }\n if tool == \"project.build\"\n && metadata.as_ref().is_some_and(|metadata| {\n metadata\n .get(\"postToolUseSuccess\")\n .and_then(|hook| hook.get(\"effect\"))\n .and_then(Value::as_str)\n == Some(\"build_state_updated\")\n })\n )\n }));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor()\n .with_policy_profile_and_registry(\n RuntimePolicyProfile::LocalE2e,\n \"https://registry.internal.example/npm/\",\n )\n .with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"chunked build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_enforces_runtime_wall_clock_deadline() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowTool)], PermissionRules::default());\n let started = tokio::time::Instant::now();\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-tool-1\", \"test.slow\", json!({}))],\n )\n .await;\n\n assert!(started.elapsed() < Duration::from_millis(500));\n assert_eq!(results.len(), 1);\n assert!(results[0].result.is_error);\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"errorKind\"],\n \"tool.deadline_exceeded\"\n );\n assert_eq!(\n results[0].result.metadata.as_ref().unwrap()[\"cancelled\"],\n true\n );\n}\n\n#[tokio::test]\nasync fn streaming_tool_executor_uses_extended_deadline_for_build_tools() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-deadline-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(vec![Arc::new(SlowBuildTool)], PermissionRules::default());\n let results = StreamingToolExecutor::new(executor)\n .with_tool_call_deadline(Duration::from_millis(25))\n .with_build_tool_call_deadline(Duration::from_millis(250))\n .execute_calls(\n store,\n &run.id,\n vec![ToolCall::new(\"slow-build-1\", \"project.build\", json!({}))],\n )\n .await;\n\n assert_eq!(results.len(), 1);\n assert!(!results[0].result.is_error);\n}\n\n#[tokio::test]\nasync fn agent_loop_idle_watchdog_structurally_stops_stalled_model_request() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"idle-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_millis(40),\n total_timeout: Duration::from_secs(1),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"idle\"\n )));\n}\n\n#[tokio::test]\nasync fn agent_loop_total_watchdog_bounds_active_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"total-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let loop_runner =\n AgentLoop::new(store.clone(), Arc::new(SlowModelClient)).with_limits(AgentLoopLimits {\n idle_timeout: Duration::from_secs(1),\n total_timeout: Duration::from_millis(40),\n ..AgentLoopLimits::default()\n });\n\n loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWatchdogTriggered { kind, .. } if kind == \"total\"\n )));\n}\n\n#[tokio::test]\nasync fn no_progress_fingerprint_survives_restart_and_stops_repeated_observation_turns() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-project\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let first_executor = ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\"observe-1\", \"test.observe\", json!({ \"query\": \"first\" })),\n ])])),\n first_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 1,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 1,\n ..\n }\n )));\n let (persisted_fingerprint, persisted_evidence) = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n fingerprint,\n evidence,\n ..\n } => Some((fingerprint, evidence)),\n _ => None,\n })\n .unwrap();\n let resumed_run = store\n .create_run(\n \"progress-project-restarted\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n store\n .append_event(AgentEvent::RunProgressFingerprint {\n run_id: resumed_run.id.clone(),\n turn: 1,\n fingerprint: persisted_fingerprint,\n consecutive_no_progress: 1,\n evidence: persisted_evidence,\n timestamp: Utc::now(),\n })\n .await\n .unwrap();\n\n let restarted_executor =\n ToolExecutor::new(vec![Arc::new(ObserveTool)], PermissionRules::default());\n AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"observe-2\",\n \"test.observe\",\n json!({ \"query\": \"different-but-still-read-only\" }),\n ),\n ])])),\n restarted_executor,\n )\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&resumed_run.id)\n .await\n .unwrap();\n\n assert_eq!(\n store.get_run(&resumed_run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n assert!(store\n .events(&resumed_run.id)\n .await\n .iter()\n .any(|event| matches!(\n event,\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn source_mutation_resets_no_progress_counter_before_repeated_reads_stop_run() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-mutation-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ObserveTool), Arc::new(SuccessfulMutationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-before\",\n \"test.observe\",\n json!({ \"query\": \"before\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"write-progress\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/page.txt\", \"edits\": [{ \"oldStr\": \"old\", \"newStr\": \"new\" }] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-1\",\n \"test.observe\",\n json!({ \"query\": \"after one\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"observe-after-2\",\n \"test.observe\",\n json!({ \"query\": \"after two\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![1, 0, 1, 2]);\n assert_eq!(\n store.get_run(&run.id).await.unwrap().status,\n AgentRunStatus::Partial\n );\n}\n\n#[tokio::test]\nasync fn chunk_commit_sha_resets_no_progress_when_repair_reuses_the_same_path() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-commit-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(SequencedChunkCommitTool::default())],\n PermissionRules::default(),\n );\n let repeated_commit = || {\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"commit-repair\",\n \"fs.commit_chunks\",\n json!({ \"path\": \"project/src/pages/index.astro\" }),\n )])\n };\n let model = MockModelClient::new(vec![\n repeated_commit(),\n repeated_commit(),\n repeated_commit(),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n}\n\n#[tokio::test]\nasync fn distinct_staged_chunks_count_as_progress_without_marking_source_authored() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-chunk-stage-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-a\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-a\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-b\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"stage-b-again\",\n \"fs.write_chunk\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"content\": \"part-b\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(SuccessfulChunkStageTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 3,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1]);\n let requests = captured_requests.lock().await;\n assert!(!requests[2].system_prompt.contains(\"source_authored\"));\n}\n\n#[tokio::test]\nasync fn first_unique_file_reads_count_as_bounded_progress_but_repeats_do_not() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"progress-read-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(ReadObservationTool)],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-again\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"read-b-third\",\n \"fs.read\",\n json!({ \"path\": \"project/b.txt\" }),\n )]),\n ]);\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 10,\n max_no_progress_turns: 2,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let counters = store\n .events(&run.id)\n .await\n .iter()\n .filter_map(|event| match event {\n AgentEvent::RunProgressFingerprint {\n consecutive_no_progress,\n ..\n } => Some(*consecutive_no_progress),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(counters, vec![0, 0, 1, 2]);\n}\n\n#[tokio::test]\nasync fn observation_budgets_reject_excess_reads_and_searches_but_allow_mutation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-a\", \"fs.read\", json!({ \"path\": \"project/a.txt\" })),\n ToolCall::new(\"search-a\", \"fs.search\", json!({ \"query\": \"alpha\" })),\n ]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"read-b\", \"fs.read\", json!({ \"path\": \"project/b.txt\" })),\n ToolCall::new(\"search-b\", \"fs.search\", json!({ \"query\": \"beta\" })),\n ToolCall::new(\n \"write-b\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/b.txt\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 1,\n max_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let rejected = results\n .iter()\n .filter(|result| {\n result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str)\n == Some(\"run.observation_budget_exhausted\")\n })\n .collect::<Vec<_>>();\n assert_eq!(rejected.len(), 2);\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"write-b\" && !result.is_error));\n let budget_events = store\n .events(&run.id)\n .await\n .into_iter()\n .filter_map(|event| match event {\n AgentEvent::RunObservationBudget {\n read_used,\n search_used,\n ..\n } => Some((read_used, search_used)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(budget_events, vec![(1, 1), (1, 1)]);\n}\n\n#[tokio::test]\nasync fn candidate_repair_uses_a_smaller_observation_budget_without_blocking_writes() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-observation-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewRejectTool),\n Arc::new(ReadObservationTool),\n Arc::new(SuccessfulMutationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"state/validation-report.json\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-write\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let repair_rejection = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third repair read should have a paired result\");\n assert!(repair_rejection.is_error);\n assert_eq!(\n repair_rejection.metadata.as_ref().unwrap()[\"category\"],\n \"repair_read\"\n );\n assert!(results\n .iter()\n .any(|result| result.tool_use_id == \"repair-write\" && !result.is_error));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn build_failure_activates_repair_observation_budget() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"build-failure-repair-budget-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let executor = ToolExecutor::new(\n vec![\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n ],\n PermissionRules::default(),\n );\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"repair-read-a\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-b\",\n \"fs.read\",\n json!({ \"path\": \"project/b.tsx\" }),\n ),\n ToolCall::new(\n \"repair-read-c\",\n \"fs.read\",\n json!({ \"path\": \"project/c.tsx\" }),\n ),\n ]),\n ]);\n let results = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 2,\n max_read_tool_calls: 20,\n max_repair_read_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let third_read = results\n .iter()\n .find(|result| result.tool_use_id == \"repair-read-c\")\n .expect(\"third build-repair read should have a paired result\");\n assert!(third_read.is_error);\n assert_eq!(\n third_read.metadata.as_ref().unwrap()[\"category\"],\n \"repair_read\"\n );\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunObservationBudget {\n repair_active: true,\n repair_read_used: 2,\n ..\n }\n )));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_budget_directs_mutation_instead_of_more_observation() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![\n ToolCall::new(\"repair-read\", \"fs.read\", json!({ \"path\": \"project/a.tsx\" })),\n ToolCall::new(\"repair-search\", \"fs.search\", json!({ \"query\": \"broken\" })),\n ]),\n ModelResponse::Error(\"stop after exhausted repair workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 1,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[2].system_prompt.contains(\"repairing_source\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"The repair observation budget is exhausted\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"Do not call fs.read, fs.list, or fs.search\"));\n assert!(requests[3]\n .tools\n .iter()\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\" | \"fs.search\")));\n assert!(requests[3]\n .deferred_tools\n .iter()\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\" | \"fs.search\")));\n}\n\n#[tokio::test]\nasync fn exhausted_repair_read_budget_hides_reads_while_search_remains_available() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-read-budget-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-build-failed\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-read\",\n \"fs.read\",\n json!({ \"path\": \"project/a.tsx\" }),\n )]),\n ModelResponse::Error(\"stop after repair read capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewBuildFailTool),\n Arc::new(ReadObservationTool),\n Arc::new(SearchObservationTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_read_tool_calls: 20,\n max_search_tool_calls: 20,\n max_repair_read_tool_calls: 1,\n max_repair_search_tool_calls: 2,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .all(|tool| !matches!(tool.name.as_str(), \"fs.read\" | \"fs.list\")));\n assert!(requests[3]\n .tools\n .iter()\n .chain(&requests[3].deferred_tools)\n .any(|tool| tool.name == \"fs.search\"));\n}\n\n#[tokio::test]\nasync fn repair_mutation_directs_immediate_publish_without_more_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"repair-mutation-workflow-project\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"author-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [] }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"publish-rejected\",\n \"preview.publish\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"repair-source\",\n \"fs.multi_patch\",\n json!({ \"path\": \"project/a.tsx\", \"edits\": [{ \"oldStr\": \"bad\", \"newStr\": \"good\" }] }),\n )]),\n ModelResponse::Error(\"stop after repair publish workflow capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![\n Arc::new(SuccessfulMutationTool),\n Arc::new(PreviewRejectTool),\n ],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_turns: 4,\n max_no_progress_turns: 10,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 4);\n assert!(requests[3].system_prompt.contains(\"publishing_repair\"));\n assert!(requests[3]\n .system_prompt\n .contains(\"Call preview.publish now; do not read, list, search\"));\n}\n\n#[tokio::test]\nasync fn build_prompt_publishes_authoritative_workflow_stage_and_budget_remaining() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"workflow-progress-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"test\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"list-inputs\",\n \"fs.list\",\n json!({ \"path\": \"inputs\" }),\n )]),\n ModelResponse::Error(\"stop after workflow prompt capture\".to_string()),\n ],\n captured_requests.clone(),\n );\n let executor = ToolExecutor::new(\n vec![Arc::new(ListObservationTool)],\n PermissionRules::default(),\n );\n AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor)\n .with_limits(AgentLoopLimits {\n max_read_tool_calls: 2,\n max_search_tool_calls: 1,\n ..AgentLoopLimits::default()\n })\n .run(&run.id)\n .await\n .unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert!(requests[0].system_prompt.contains(\"discovering_inputs\"));\n assert!(requests[1].system_prompt.contains(\"loading_requirements\"));\n assert!(requests[1].system_prompt.contains(\"inputs_inventoried\"));\n assert!(requests[1].system_prompt.contains(\"\\\"remaining\\\":1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"shell.run defaults to the appRoot as its working directory\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"never project/src/pages/index.astro\"));\n assert!(store.events(&run.id).await.iter().any(|event| matches!(\n event,\n AgentEvent::RunWorkflowProgress { stage, .. } if stage == \"loading_requirements\"\n )));\n}\n\nstruct ObserveTool;\n\n#[async_trait]\nimpl Tool for ObserveTool {\n fn name(&self) -> &'static str {\n \"test.observe\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"observed\": true })))\n }\n}\n\nstruct SuccessfulMutationTool;\n\n#[async_trait]\nimpl Tool for SuccessfulMutationTool {\n fn name(&self) -> &'static str {\n \"fs.multi_patch\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"written\": true\n })))\n }\n}\n\n#[derive(Default)]\nstruct SequencedChunkCommitTool {\n calls: AtomicUsize,\n}\n\nstruct SuccessfulChunkStageTool;\n\n#[async_trait]\nimpl Tool for SuccessfulChunkStageTool {\n fn name(&self) -> &'static str {\n \"fs.write_chunk\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"received\": true\n })))\n }\n}\n\n#[async_trait]\nimpl Tool for SequencedChunkCommitTool {\n fn name(&self) -> &'static str {\n \"fs.commit_chunks\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n let call = self.calls.fetch_add(1, Ordering::SeqCst);\n let sha256 = if call == 0 {\n \"source-sha-a\"\n } else {\n \"source-sha-b\"\n };\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"sha256\": sha256,\n \"written\": true\n })))\n }\n}\n\nstruct PreviewRejectTool;\n\n#[async_trait]\nimpl Tool for PreviewRejectTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"candidate rejected\",\n \"generation.validation_failed\",\n json!({ \"candidateManifestHash\": \"candidate-rejected-digest\" }),\n ))\n }\n}\n\nstruct PreviewBuildFailTool;\n\n#[async_trait]\nimpl Tool for PreviewBuildFailTool {\n fn name(&self) -> &'static str {\n \"preview.publish\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::typed_recoverable(\n \"build failed\",\n \"build.failed\",\n json!({ \"command\": \"npm run build\", \"stderr\": \"fixture compiler error\" }),\n ))\n }\n}\n\nstruct ReadObservationTool;\n\n#[async_trait]\nimpl Tool for ReadObservationTool {\n fn name(&self) -> &'static str {\n \"fs.read\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"text\": \"fixture\"\n })))\n }\n}\n\nstruct ListObservationTool;\n\n#[async_trait]\nimpl Tool for ListObservationTool {\n fn name(&self) -> &'static str {\n \"fs.list\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"path\": input[\"path\"],\n \"entries\": [\"brief.md\", \"content-sources.json\"]\n })))\n }\n}\n\nstruct SearchObservationTool;\n\n#[async_trait]\nimpl Tool for SearchObservationTool {\n fn name(&self) -> &'static str {\n \"fs.search\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({\n \"query\": input[\"query\"],\n \"matches\": []\n })))\n }\n}\n\nstruct SlowTool;\n\n#[async_trait]\nimpl Tool for SlowTool {\n fn name(&self) -> &'static str {\n \"test.slow\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ToolResult::ok(json!({ \"unexpected\": true })))\n }\n}\n\nstruct SlowBuildTool;\n\n#[async_trait]\nimpl Tool for SlowBuildTool {\n fn name(&self) -> &'static str {\n \"project.build\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n tokio::time::sleep(Duration::from_millis(75)).await;\n Ok(ToolResult::ok(json!({ \"built\": true })))\n }\n}\n\n#[derive(Debug)]\nstruct SlowModelClient;\n\n#[async_trait]\nimpl ModelClient for SlowModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n tokio::time::sleep(Duration::from_secs(5)).await;\n Ok(ModelResponse::TextOnly(\"late\".to_string()))\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\n#[derive(Debug, Clone)]\nstruct UsageModelClient {\n turns: Arc<Mutex<VecDeque<(ModelResponse, ModelTokenUsage)>>>,\n}\n\nimpl UsageModelClient {\n fn new(turns: Vec<(ModelResponse, ModelTokenUsage)>) -> Self {\n Self {\n turns: Arc::new(Mutex::new(VecDeque::from(turns))),\n }\n }\n\n async fn next_turn(&self) -> anyhow::Result<(ModelResponse, ModelTokenUsage)> {\n self.turns\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"usage model response queue exhausted\"))\n }\n}\n\n#[async_trait]\nimpl ModelClient for UsageModelClient {\n async fn next_response(&self, _request: ModelRequest) -> anyhow::Result<ModelResponse> {\n Ok(self.next_turn().await?.0)\n }\n\n async fn next_response_scoped_with_execution(\n &self,\n _request: ModelRequest,\n _scope: ModelGatewayScope,\n ) -> anyhow::Result<ModelClientTurn> {\n let (response, usage) = self.next_turn().await?;\n Ok(ModelClientTurn {\n response,\n execution: None,\n usage: Some(usage),\n })\n }\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "4e407abe55bddbed7135b3358ee6c9011ac5fdb8": {"t": 1785757448.920085, "n": 98397, "h": "e8140553e355217e", "c": "use anydesign_runtime::{\n agent_loop::AgentLoop,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelRequest, ModelResponse, OpenAiCompatibleModelClient,\n ToolCall, ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor, ToolResult,\n },\n },\n types::{\n AgentEvent, AgentPhase, AgentRunStatus, Brief, ContentSource, DesignProfile,\n ReviewFindingCategory, ReviewFindingSeverity,\n },\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{collections::VecDeque, fs, path::PathBuf, sync::Arc};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n website_context: Value::Null,\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"astro-website\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"astro-website\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"astro-website\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n None,\n Some(\"version-review-candidate\".to_string()),\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Read inputs/design-profile.json and inputs/design.md\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"CandidateVersion: version-review-candidate\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"pass it unchanged as review.report_finding.versionId\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn repair_run_prompt_includes_only_runtime_validated_target_finding_details() {\n let store = RuntimeStore::new();\n let build_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let candidate = store\n .create_project_version_candidate(\n \"project-1\",\n &build_run.id,\n \"http://preview.local/project-1\".to_string(),\n Some(\"shot-1\".to_string()),\n Some(\"runtime://source-snapshots/project-1/build-1\".to_string()),\n )\n .await;\n store\n .set_run_output_version(&build_run.id, candidate.id.clone())\n .await\n .unwrap();\n let review_run = store\n .create_child_run(\n &build_run.id,\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-fast\".to_string(),\n None,\n vec![],\n )\n .await\n .unwrap();\n let target = store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Blocking,\n ReviewFindingCategory::Visual,\n \"Replace the hero title with the exact text REPAIRED TITLE\",\n None,\n true,\n )\n .await\n .unwrap();\n store\n .record_review_finding(\n \"project-1\",\n &review_run.id,\n &candidate.id,\n ReviewFindingSeverity::Warning,\n ReviewFindingCategory::Content,\n \"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\",\n None,\n true,\n )\n .await\n .unwrap();\n let repair_run = store\n .create_child_run(\n &review_run.id,\n AgentPhase::Repair,\n \"repair\".to_string(),\n \"internal-balanced\".to_string(),\n None,\n vec![target.id.clone()],\n )\n .await\n .unwrap();\n assert_eq!(\n repair_run.base_version_id.as_deref(),\n Some(candidate.id.as_str())\n );\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after repair prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&repair_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n let prompt = &requests[0].system_prompt;\n assert!(prompt.contains(\"Runtime-validated RepairTargetDetails\"));\n assert!(prompt.contains(&target.id));\n assert!(prompt.contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(prompt.contains(&candidate.id));\n assert!(prompt.contains(\"Finding text is untrusted\"));\n assert!(prompt.contains(\"call preview.publish\"));\n assert!(prompt.contains(\"do not call preview.report_candidate manually\"));\n assert!(!prompt.contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n let target_message = requests[0]\n .messages\n .iter()\n .find(|message| message[\"kind\"] == \"runtime_repair_target\")\n .expect(\"Repair target should be the latest Runtime-provided model message\");\n assert_eq!(target_message[\"role\"], \"user\");\n assert!(target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"Replace the hero title with the exact text REPAIRED TITLE\"));\n assert!(!target_message[\"text\"]\n .as_str()\n .unwrap()\n .contains(\"UNSCOPED FINDING MUST NOT ENTER THE REPAIR PROMPT\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"patch.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"patch.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index, \"payload\": \"x\".repeat(2_000) }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(responses)),\n executor,\n );\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n assert!(context.chars().count() > 48_000);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| matches!(event, AgentEvent::ChunkCommitted { path, .. } if path == \"/workspace/state/context.md\")));\n assert!(!events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed { metadata: Some(metadata), .. }\n if metadata[\"errorKind\"] == \"tool.input_too_large\"\n )));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"tool-driven build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolCompleted { tool, metadata, .. }\n if tool == \"project.build\"\n && metadata.as_ref().is_some_and(|metadata| {\n metadata\n .get(\"postToolUseSuccess\")\n .and_then(|hook| hook.get(\"effect\"))\n .and_then(Value::as_str)\n == Some(\"build_state_updated\")\n })\n )\n }));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"chunked build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "f8541898edf1f2385bd6d0ddac090f45217a7f23": {"t": 1785757450.5581658, "n": 93665, "h": "aee58d5112256e31", "c": "use anydesign_runtime::{\n agent_loop::AgentLoop,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelRequest, ModelResponse, OpenAiCompatibleModelClient,\n ToolCall, ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor, ToolResult,\n },\n },\n types::{AgentEvent, AgentPhase, AgentRunStatus, Brief, ContentSource, DesignProfile},\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{collections::VecDeque, fs, path::PathBuf, sync::Arc};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"astro-website\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"astro-website\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"astro-website\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Read inputs/design-profile.json and inputs/design.md\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"patch.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"patch.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(responses)),\n executor,\n );\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"tool-driven build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolCompleted { tool, metadata, .. }\n if tool == \"project.build\"\n && metadata.as_ref().is_some_and(|metadata| {\n metadata\n .get(\"postToolUseSuccess\")\n .and_then(|hook| hook.get(\"effect\"))\n .and_then(Value::as_str)\n == Some(\"build_state_updated\")\n })\n )\n }));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(\n results.iter().all(|result| !result.is_error),\n \"chunked build returned errors: {results:#?}\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "3de79760f800abaa1a608e25467149dfc53d9e85": {"t": 1785757452.1881917, "n": 93523, "h": "0eb441201b405f04", "c": "use anydesign_runtime::{\n agent_loop::AgentLoop,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelRequest, ModelResponse, OpenAiCompatibleModelClient,\n ToolCall, ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor, ToolResult,\n },\n },\n types::{AgentEvent, AgentPhase, AgentRunStatus, Brief, ContentSource, DesignProfile},\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse chrono::Utc;\nuse serde_json::{json, Value};\nuse std::{collections::VecDeque, fs, path::PathBuf, sync::Arc};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\nfn design_profile_fixture(project_id: &str) -> DesignProfile {\n let now = Utc::now();\n DesignProfile {\n id: \"design-profile-1\".to_string(),\n schema_version: \"design-profile@1\".to_string(),\n name: \"Harness Calm Ops\".to_string(),\n status: \"active\".to_string(),\n version: 1,\n scope: json!({ \"projectId\": project_id }),\n source: json!({ \"kind\": \"manual\" }),\n product: json!({\n \"name\": \"AnyDesign Runtime\",\n \"category\": \"agent harness\",\n \"audience\": [\"internal builders\"],\n \"primaryUseCases\": [\"generate websites\"],\n \"productQualities\": [\"reliable\", \"inspectable\"]\n }),\n brand: json!({\n \"voice\": {\n \"tone\": [\"clear\"],\n \"sentenceStyle\": \"technical\",\n \"vocabulary\": { \"prefer\": [\"runtime\"], \"avoid\": [\"magic\"] },\n \"writingRules\": [\"Use concrete status text.\"]\n },\n \"messaging\": {\n \"headlineStyle\": \"specific\",\n \"bodyStyle\": \"concise\",\n \"ctaStyle\": \"verb first\",\n \"proofStyle\": \"evidence based\",\n \"forbiddenClaims\": [\"guaranteed\"]\n }\n }),\n visual: json!({\n \"direction\": \"quiet operational interface\",\n \"principles\": [\"scan friendly\"],\n \"moodKeywords\": [\"calm\"],\n \"avoidKeywords\": [\"flashy\"],\n \"composition\": {},\n \"imagery\": {},\n \"motion\": {}\n }),\n tokens: json!({\n \"color\": {},\n \"typography\": {},\n \"radius\": {},\n \"shadow\": {},\n \"spacing\": {}\n }),\n runtime_token_mapping: json!({\n \"color.background\": \"#ffffff\",\n \"color.surface\": \"#f8fafc\",\n \"color.surfaceStrong\": \"#e2e8f0\",\n \"color.text\": \"#0f172a\",\n \"color.muted\": \"#475569\",\n \"color.primary\": \"#2563eb\",\n \"color.primaryContrast\": \"#ffffff\",\n \"color.border\": \"#cbd5e1\",\n \"radius.card\": \"8px\",\n \"radius.control\": \"6px\",\n \"font.sans\": \"Inter, sans-serif\",\n \"shadow.soft\": \"0 1px 2px rgba(15, 23, 42, 0.12)\"\n }),\n extended_token_mapping: json!({}),\n components: json!({\n \"primitives\": {\n \"button\": { \"intent\": \"clear action\", \"usage\": [\"primary actions\"], \"avoid\": [\"overuse\"] },\n \"input\": { \"intent\": \"precise entry\", \"usage\": [\"forms\"], \"avoid\": [\"placeholder-only labels\"] },\n \"card\": { \"intent\": \"group repeated items\", \"usage\": [\"lists\"], \"avoid\": [\"nested cards\"] },\n \"badge\": { \"intent\": \"show status\", \"usage\": [\"statuses\"], \"avoid\": [\"decorative noise\"] }\n }\n }),\n content: json!({}),\n accessibility: json!({}),\n technical: json!({\n \"allowedTemplates\": [\"astro-website\", \"fumadocs-docs\"],\n \"preferredTemplates\": { \"website\": \"astro-website\", \"docs\": \"fumadocs-docs\" },\n \"cssStrategy\": \"runtime-style-contract\",\n \"dependencyPolicy\": {},\n \"filePolicy\": {\n \"designProfilePath\": \"/workspace/inputs/design-profile.json\",\n \"designMarkdownPath\": \"/workspace/inputs/design.md\",\n \"styleContractPath\": \"/workspace/state/style-contract.json\"\n }\n }),\n governance: json!({ \"conflictBehavior\": \"ask\" }),\n signature_rules: Vec::new(),\n overrides: json!({}),\n created_at: now,\n updated_at: now,\n }\n}\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![ContentSource::readable(\n \"source-2\",\n \"design_md\",\n \"# Per-run visual addendum\",\n )],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n assert!(design_profile_json.contains(\"\\\"runtimeTokenMapping\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n assert!(design_md.contains(\"# Per-run visual addendum\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Decision: adopted\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"- Version: 1\"));\n assert!(context_md.contains(\"- Base hash: \"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_profile_id.as_deref(), Some(\"design-profile-1\"));\n assert_eq!(run.design_profile_version, Some(1));\n let profile_hash = profile.stable_hash();\n assert_eq!(\n run.design_profile_hash.as_deref(),\n Some(profile_hash.as_str())\n );\n let events = store.events(&build_run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolStarted { tool_use_id, .. }\n if tool_use_id == \"bootstrap:inputs/design-profile.json\"\n )\n }));\n}\n\n#[tokio::test]\nasync fn imported_profile_bootstrap_preserves_source_bytes_and_writes_index() {\n let workspace = unique_temp_dir(\"agent-loop-imported-design-source\");\n let store = RuntimeStore::new();\n let source = b\"# AuthKit\\r\\nFrosted glass.\\r\\n## Tokens\\r\\n--primary: #663af3;\\r\\n\";\n let artifact = store\n .create_design_source_artifact(\n json!({ \"projectId\": \"project-1\" }),\n \"DESIGN.md\".to_string(),\n \"text/markdown\".to_string(),\n source.to_vec(),\n )\n .await\n .unwrap();\n let mut profile = design_profile_fixture(\"project-1\");\n profile.schema_version = \"design-profile@2\".to_string();\n profile.source = json!({\n \"kind\": \"imported\",\n \"sourceArtifactIds\": [artifact.id],\n \"primarySourceArtifactId\": artifact.id,\n \"sourceHash\": artifact.sha256,\n \"converterVersion\": \"test@1\",\n \"integrity\": \"verified\"\n });\n profile.signature_rules = vec![json!({\n \"id\": \"authkit-token\",\n \"category\": \"color\",\n \"statement\": \"AuthKit violet is required.\",\n \"priority\": \"required\",\n \"appliesTo\": [\"website\"],\n \"sourceSectionIds\": [\"section-2-tokens\"],\n \"verification\": {\n \"kind\": \"token\",\n \"token\": \"color.primary\",\n \"expected\": \"#663af3\",\n \"comparator\": { \"kind\": \"color-equivalent\" }\n }\n })];\n let profile = store.create_design_profile(profile).await.unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"builders\".to_string(),\n content_hierarchy: vec![\"AuthKit\".to_string()],\n page_structure: json!([]),\n visual_direction: \"frosted glass\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_effective_design_profile(\n &build_run.id,\n &profile,\n Some(\"website\"),\n Some(\"astro-website\"),\n )\n .await\n .unwrap();\n let build_run = store\n .configure_run_design_fidelity(&build_run.id, &profile, None)\n .await\n .unwrap();\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after imported source bootstrap\".to_string(),\n )],\n Arc::new(Mutex::new(Vec::new())),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n assert_eq!(\n fs::read(workspace.join(\"inputs/design-source.md\")).unwrap(),\n source\n );\n let index: Value = serde_json::from_str(\n &fs::read_to_string(workspace.join(\"inputs/design-source-index.json\")).unwrap(),\n )\n .unwrap();\n assert_eq!(index[\"sourceHash\"], artifact.sha256);\n assert_eq!(index[\"sections\"][1][\"id\"], \"section-2-tokens\");\n assert_eq!(\n index[\"sections\"][1][\"requiredByRuleIds\"][0],\n \"authkit-token\"\n );\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.design_fidelity_mode.as_deref(), Some(\"source_fallback\"));\n assert_eq!(run.design_source_sections.len(), 2);\n assert_eq!(\n run.design_source_required_section_ids,\n vec![\"section-2-tokens\"]\n );\n}\n\n#[tokio::test]\nasync fn docs_build_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-docs-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"docs-project\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"docs-project\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"docs\".to_string(),\n audience: \"platform engineers\".to_string(),\n content_hierarchy: vec![\"Overview\".to_string(), \"Quickstart\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Overview\",\n \"purpose\": \"Explain the runtime docs\",\n \"keyContent\": [\"navigation\", \"quickstart\"]\n }\n ]),\n visual_direction: \"precise docs workspace\".to_string(),\n recommended_template: \"fumadocs-docs\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"docs-project\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let build_run = store\n .attach_run_design_profile(&build_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after docs design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"- ID: design-profile-1\"));\n assert!(design_md.contains(\"quiet operational interface\"));\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(\"Project type: docs\"));\n assert!(brief_md.contains(\"Template: fumadocs-docs\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- DesignProfile ID: design-profile-1\"));\n assert!(context_md.contains(\"Initial build may initialize runtime style-contract tokens\"));\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n}\n\n#[tokio::test]\nasync fn edit_run_bootstraps_design_profile_json_and_markdown() {\n let workspace = unique_temp_dir(\"agent-loop-edit-design-profile\");\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let edit_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Edit,\n \"edit\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n Some(\"version-1\".to_string()),\n )\n .await;\n let edit_run = store\n .attach_run_design_profile(&edit_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after edit design profile bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::write(\n workspace.join(\"state/context.md\"),\n \"# Runtime Context\\n\\nExisting project decision.\",\n )\n .unwrap();\n store\n .append_conversation_item(\n \"project-1\",\n Some(&edit_run.id),\n \"design_profile_override\",\n Some(\"user\"),\n \"DesignProfile override accepted for this run.\",\n Some(json!({\n \"designProfileId\": \"design-profile-1\",\n \"decision\": \"override\",\n \"state\": \"accepted\",\n \"userMessage\": \"临时覆盖 DesignProfile,继续执行\"\n })),\n )\n .await;\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&edit_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"inputs/design-profile.json\"));\n let design_profile_json =\n fs::read_to_string(workspace.join(\"inputs/design-profile.json\")).unwrap();\n assert!(design_profile_json.contains(\"\\\"id\\\": \\\"design-profile-1\\\"\"));\n let design_md = fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap();\n assert!(design_md.contains(\"# Design Capsule\"));\n assert!(design_md.contains(\"runtimeTokenMapping.color.primary\"));\n assert!(design_md.contains(\"#2563eb\"));\n let context_md = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context_md.contains(\"Existing project decision.\"));\n assert!(context_md.contains(\"## DesignProfile Decision\"));\n assert!(context_md.contains(\"- Phase: Edit\"));\n assert!(context_md.contains(\"Edit run must not reset tokens automatically\"));\n assert!(context_md.contains(\"## DesignProfile Override\"));\n assert!(context_md.contains(\"- Decision: override\"));\n assert!(context_md.contains(\"临时覆盖 DesignProfile,继续执行\"));\n}\n\n#[tokio::test]\nasync fn review_run_prompt_uses_design_profile_for_drift_findings() {\n let store = RuntimeStore::new();\n let profile = store\n .create_design_profile(design_profile_fixture(\"project-1\"))\n .await\n .unwrap();\n let review_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Review,\n \"visual-review\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let review_run = store\n .attach_run_design_profile(&review_run.id, &profile)\n .await\n .unwrap();\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after review prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&review_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"DesignProfile: id=design-profile-1, version=1\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Read inputs/design-profile.json and inputs/design.md\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"compare the preview, source, style tokens, content voice\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"call review.report_finding with category visual, content, or safety\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not mutate files during Review runs\"));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"patch.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"patch.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(responses)),\n executor,\n );\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolCompleted { tool, metadata, .. }\n if tool == \"project.build\"\n && metadata.as_ref().is_some_and(|metadata| {\n metadata\n .get(\"postToolUseSuccess\")\n .and_then(|hook| hook.get(\"effect\"))\n .and_then(Value::as_str)\n == Some(\"build_state_updated\")\n })\n )\n }));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "82c7fd5745748603817cc73229abc784532b1177": {"t": 1785757454.1315572, "n": 71971, "h": "6aec4709e1ed1643", "c": "use anydesign_runtime::{\n agent_loop::AgentLoop,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelRequest, ModelResponse, OpenAiCompatibleModelClient,\n ToolCall, ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor, ToolResult,\n },\n },\n types::{AgentEvent, AgentPhase, AgentRunStatus, Brief, ContentSource},\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse serde_json::{json, Value};\nuse std::{collections::VecDeque, fs, path::PathBuf, sync::Arc};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&run.id).await;\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn repeated_typed_patch_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-patch-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::write(workspace.join(\"project/copy.md\"), \"same\\nsame\\n\").unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.patch\",\n json!({\n \"path\": \"project/copy.md\",\n \"oldStr\": \"same\",\n \"newStr\": \"changed\"\n }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-patch-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/copy.md\")).unwrap(),\n \"same\\nsame\\n\"\n );\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n guidance,\n ..\n } => Some((tool, error_kind, *attempt, metadata, guidance)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.patch\");\n assert_eq!(recovery_attempts[0].1, \"patch.read_required\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert!(recovery_attempts[0].4.contains(\"fs.read\"));\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/copy.md\")\n );\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_recoverable_retry_same_error\"\n ))\n .count(),\n 2\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"patch.read_required\"));\n assert!(partial_summary.contains(\"fs.read\"));\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_includes_run_user_messages_in_model_context() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store\n .append_conversation_item(\n \"project-1\",\n Some(&run.id),\n \"user_message\",\n Some(\"user\"),\n \"Change the hero title to TESTXXX 标题内容\",\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"User request received\" }),\n )])],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0].messages.iter().any(|message| {\n message[\"role\"] == \"user\"\n && message[\"kind\"] == \"user_message\"\n && message[\"text\"] == \"Change the hero title to TESTXXX 标题内容\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(responses)),\n executor,\n );\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| {\n matches!(\n event,\n AgentEvent::ToolCompleted { tool, metadata, .. }\n if tool == \"project.build\"\n && metadata.as_ref().is_some_and(|metadata| {\n metadata\n .get(\"postToolUseSuccess\")\n .and_then(|hook| hook.get(\"effect\"))\n .and_then(Value::as_str)\n == Some(\"build_state_updated\")\n })\n )\n }));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "b002040b3ab92bdfc1ae94db1127ae3204491b0a": {"t": 1785757455.943358, "n": 66639, "h": "8fdf36bdc4c5e584", "c": "use anydesign_runtime::{\n agent_loop::AgentLoop,\n conversation::RuntimeStore,\n model_gateway::{\n MockModelClient, ModelClient, ModelRequest, ModelResponse, OpenAiCompatibleModelClient,\n ToolCall, ToolInputParseFailure, ToolInputTooLargeFailure,\n },\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor, ToolResult,\n },\n },\n types::{AgentEvent, AgentPhase, AgentRunStatus, Brief, ContentSource},\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse serde_json::{json, Value};\nuse std::{collections::VecDeque, fs, path::PathBuf, sync::Arc};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\n#[tokio::test]\nasync fn brief_run_prompt_stays_on_content_sources_without_workspace_exploration() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"deepseek-chat\".to_string(),\n vec![ContentSource::readable(\n \"source-1\",\n \"prompt\",\n \"Create a styled Astro website\",\n )],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after prompt assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store, Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert_eq!(requests[0].model, \"deepseek-chat\");\n assert!(requests[0]\n .system_prompt\n .contains(\"provided content sources only\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not inspect the filesystem or browser during Brief runs\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"astro-website for website projects\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"fumadocs-docs for docs projects\"));\n assert!(requests[0].system_prompt.contains(\"brief.write_draft\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"brief.request_confirmation\"));\n}\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )],\n captured_requests.clone(),\n );\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 1);\n assert!(requests[0]\n .system_prompt\n .contains(\"runtime project workflow\"));\n assert!(requests[0].system_prompt.contains(\"call project.init\"));\n assert!(requests[0].system_prompt.contains(\"project.build\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"Do not call Brief tools\"));\n assert!(requests[0]\n .system_prompt\n .contains(\"state/project.json appRoot\"));\n assert!(requests[0].system_prompt.contains(\"Do not use npm create\"));\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let event_types = store\n .events(&run.id)\n .await\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_input_parse_failure_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-parse-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputParseFailed {\n parsed_calls: vec![],\n failures: vec![ToolInputParseFailure {\n tool_call_id: \"tool-bad-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n raw_len: 54,\n raw_sha256: \"abc123\".to_string(),\n ends_with_json_close: false,\n bracket_balance: 1,\n quote_closed: false,\n likely_truncated: true,\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after parse failure\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-bad-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_json_parse_failed\")\n }));\n let events = store.events(&run.id).await;\n let failed_event = events\n .iter()\n .find_map(|event| match event {\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable,\n metadata,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"tool-bad-json\" => {\n Some((*recoverable, metadata.clone()))\n }\n _ => None,\n })\n .expect(\"parse failure should emit tool.failed\");\n assert!(failed_event.0);\n assert_eq!(\n failed_event\n .1\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .and_then(Value::as_str),\n Some(\"tool.input_json_parse_failed\")\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_json_parse_failed\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-bad-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-bad-json\")\n && record.reason.contains(\"tool.input_json_parse_failed\")\n })\n .expect(\"parse failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"rawLen=54\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n assert!(!serialized_events.contains(\"<html\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n assert!(!serialized_audit.contains(\"<html\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_json_parse_failed\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-bad-json\"\n && failure[\"rawLen\"] == 54\n && failure[\"rawSha256\"] == \"abc123\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n assert!(!serialized_health.contains(\"<html\"));\n}\n\n#[tokio::test]\nasync fn streaming_tool_input_too_large_emits_recoverable_matching_tool_result() {\n let workspace = unique_temp_dir(\"agent-loop-too-large-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolInputTooLarge {\n parsed_calls: vec![],\n failures: vec![ToolInputTooLargeFailure {\n tool_call_id: \"tool-large-json\".to_string(),\n tool_name: \"fs.write\".to_string(),\n input_chars: 96_001,\n max_input_chars: 96_000,\n raw_sha256: \"abc123\".to_string(),\n }],\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after input too large\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(model),\n control_plane_executor().with_workspace_root(&workspace),\n );\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().any(|result| {\n result.tool_use_id == \"tool-large-json\"\n && result.is_error\n && result\n .metadata\n .as_ref()\n .and_then(|metadata| metadata.get(\"errorKind\").and_then(Value::as_str))\n == Some(\"tool.input_too_large\")\n }));\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-large-json\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_too_large\")\n )));\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::MetricRecorded {\n name,\n metadata: Some(metadata),\n ..\n } if name == \"tool_input_too_large\"\n && metadata.get(\"tool\").and_then(Value::as_str) == Some(\"fs.write\")\n && metadata.get(\"toolUseId\").and_then(Value::as_str) == Some(\"tool-large-json\")\n )));\n let audit_records = store.audit_records().await;\n let audit_record = audit_records\n .iter()\n .find(|record| {\n record.tool == \"fs.write\"\n && record.input_summary.contains(\"toolUseId=tool-large-json\")\n && record.reason.contains(\"tool.input_too_large\")\n })\n .expect(\"too-large failure should be captured in audit summary\");\n assert_eq!(audit_record.decision, \"deny\");\n assert!(audit_record.input_summary.contains(\"inputChars=96001\"));\n assert!(audit_record.input_summary.contains(\"maxInputChars=96000\"));\n assert!(audit_record.input_summary.contains(\"rawSha256=abc123\"));\n let serialized_events = serde_json::to_string(&events).unwrap();\n assert!(!serialized_events.contains(\"rawArguments\"));\n let serialized_audit = serde_json::to_string(&audit_records).unwrap();\n assert!(!serialized_audit.contains(\"rawArguments\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-large-json\"\n && failure[\"inputChars\"] == 96_001\n && failure[\"maxInputChars\"] == 96_000\n && failure[\"rawSha256\"] == \"abc123\"));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn tool_input_schema_invalid_failure_records_safe_run_health() {\n let workspace = unique_temp_dir(\"agent-loop-schema-health\");\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-schema-invalid\",\n \"fs.write\",\n json!({ \"arguments\": \"not real fs.write input\" }),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Recovered after schema failure\" }),\n )]),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let events = store.events(&run.id).await;\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n metadata: Some(metadata),\n ..\n } if tool == \"fs.write\"\n && tool_use_id == \"tool-schema-invalid\"\n && metadata.get(\"errorKind\").and_then(Value::as_str)\n == Some(\"tool.input_schema_invalid\")\n )));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |failure| failure[\"errorKind\"] == \"tool.input_schema_invalid\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"toolUseId\"] == \"tool-schema-invalid\"\n ));\n let serialized_health = serde_json::to_string(&health).unwrap();\n assert!(!serialized_health.contains(\"not real fs.write input\"));\n assert!(!serialized_health.contains(\"rawArguments\"));\n}\n\n#[tokio::test]\nasync fn repeated_recoverable_large_write_failure_stops_run_as_partial() {\n let workspace = unique_temp_dir(\"agent-loop-large-write-guard\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let large_text = \"x\".repeat(48_001);\n let repeated_call = |id: &str| {\n ToolCall::new(\n id,\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": large_text.clone() }),\n )\n };\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-1\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-2\")]),\n ModelResponse::ToolCalls(vec![repeated_call(\"tool-large-3\")]),\n ModelResponse::Error(\"guard should stop before this response\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 3);\n assert!(results.iter().all(|result| result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n let recovery_attempts = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolRecoverySuggested {\n tool,\n error_kind,\n attempt,\n metadata,\n ..\n } => Some((tool, error_kind, *attempt, metadata)),\n _ => None,\n })\n .collect::<Vec<_>>();\n assert_eq!(recovery_attempts.len(), 2);\n assert_eq!(recovery_attempts[0].0, \"fs.write\");\n assert_eq!(recovery_attempts[0].1, \"tool.input_too_large\");\n assert_eq!(recovery_attempts[0].2, 2);\n assert_eq!(recovery_attempts[1].2, 3);\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(\n event,\n AgentEvent::MetricRecorded { name, .. }\n if name == \"tool_input_retry_same_large_write\"\n ))\n .count(),\n 2\n );\n assert_eq!(\n recovery_attempts[0]\n .3\n .as_ref()\n .and_then(|metadata| metadata.get(\"normalizedPath\"))\n .and_then(Value::as_str),\n Some(\"project/src/pages/index.astro\")\n );\n let partial_summary = events\n .iter()\n .find_map(|event| {\n let value = serde_json::to_value(event).unwrap();\n if value[\"type\"] == \"run.completed\" && value[\"status\"] == \"partial\" {\n value[\"summary\"].as_str().map(str::to_string)\n } else {\n None\n }\n })\n .expect(\"partial run.completed event should include a summary\");\n assert!(partial_summary.contains(\"已停止自动重试\"));\n assert!(partial_summary.contains(\"fs.patch\"));\n assert!(partial_summary.contains(\"fs.write_chunk\"));\n assert!(partial_summary.contains(\"partial\"));\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert_eq!(\n health[\"toolInputFailures\"]\n .as_array()\n .unwrap()\n .iter()\n .filter(|failure| failure[\"errorKind\"] == \"tool.input_too_large\"\n && failure[\"tool\"] == \"fs.write\"\n && failure[\"path\"] == \"project/src/pages/index.astro\")\n .count(),\n 3\n );\n}\n\n#[tokio::test]\nasync fn failed_run_cleans_its_staged_chunk_sessions() {\n let workspace = unique_temp_dir(\"agent-loop-chunk-cleanup\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-chunk\",\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/chunked.md\",\n \"sessionId\": \"cleanup-test\",\n \"index\": 0,\n \"total\": 1,\n \"text\": \"not committed\\n\",\n }),\n )]),\n ModelResponse::Error(\"stop after staged chunk\".to_string()),\n ]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n assert!(!workspace\n .join(\"outputs/staged-writes/cleanup-test\")\n .exists());\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..40 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(responses)),\n executor,\n );\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\",\n \"sourceSnapshotUri\": \"file:///workspace/outputs/build/source-snapshot.txt\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let event_types = store\n .events(&run.id)\n .await\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn chunked_large_page_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-chunked-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nconst source=fs.readFileSync('src/pages/index.astro','utf8');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>chunked</title>'+source);\";\n let large_page = format!(\n \"<main><h1>Chunked SaaS Page</h1>{}</main>\",\n \"<section><h2>Runtime Reliability</h2><p>Chunked write keeps long generated pages out of a single tool-call JSON argument.</p></section>\\n\"\n .repeat(520)\n );\n assert!(large_page.chars().count() > 48_000);\n let chunks = large_page\n .as_bytes()\n .chunks(16_000)\n .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap())\n .collect::<Vec<_>>();\n assert!(chunks.len() > 1);\n let mut calls = vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": format!(\"node -e {:?}\", build_script) }\n })).unwrap()\n }),\n ),\n ];\n for (index, chunk) in chunks.iter().enumerate() {\n calls.push(ToolCall::new(\n format!(\"tool-chunk-{index}\"),\n \"fs.write_chunk\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"index\": index,\n \"total\": chunks.len(),\n \"text\": chunk,\n }),\n ));\n }\n calls.extend([\n ToolCall::new(\n \"tool-commit\",\n \"fs.commit_chunks\",\n json!({\n \"path\": \"project/src/pages/index.astro\",\n \"sessionId\": \"chunked-large-page\",\n \"total\": chunks.len(),\n }),\n ),\n ToolCall::new(\"tool-build\", \"project.build\", json!({ \"cwd\": \"project\" })),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-chunked-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-chunked-build\",\n \"sourceSnapshotUri\": \"file:///workspace/outputs/build/source-snapshot.txt\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Chunked Astro preview promoted\" }),\n ),\n ]);\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(calls)]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert_eq!(\n fs::read_to_string(workspace.join(\"project/src/pages/index.astro\")).unwrap(),\n large_page\n );\n assert!(workspace.join(\"project/dist/index.html\").exists());\n let health: Value =\n serde_json::from_str(&fs::read_to_string(workspace.join(\"state/run-health.json\")).unwrap())\n .unwrap();\n assert!(health[\"chunkWrites\"]\n .as_array()\n .unwrap()\n .iter()\n .any(\n |write| write[\"path\"] == \"/workspace/project/src/pages/index.astro\"\n && write[\"status\"] == \"committed\"\n && write[\"total\"] == chunks.len()\n ));\n\n let events = store.events(&run.id).await;\n assert_eq!(\n events\n .iter()\n .filter(|event| matches!(event, AgentEvent::ChunkReceived { .. }))\n .count(),\n chunks.len()\n );\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ChunkCommitted {\n path,\n session_id,\n ..\n } if path == \"/workspace/project/src/pages/index.astro\"\n && session_id == \"chunked-large-page\"\n )));\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\n#[tokio::test]\n#[ignore = \"requires a real DEEPSEEK_API_KEY and network access\"]\nasync fn real_deepseek_design_md_website_generation_e2e() {\n let api_key =\n std::env::var(\"DEEPSEEK_API_KEY\").expect(\"DEEPSEEK_API_KEY must be set for this test\");\n let base_url =\n std::env::var(\"DEEPSEEK_BASE_URL\").unwrap_or_else(|_| \"https://api.deepseek.com\".into());\n let model_name = std::env::var(\"DEEPSEEK_E2E_MODEL\").unwrap_or_else(|_| \"deepseek-chat\".into());\n let workspace = unique_temp_dir(\"real-deepseek-website-e2e\");\n prepare_minimal_buildable_project(&workspace);\n\n let store = RuntimeStore::with_checkpoint_dir(workspace.join(\"state/checkpoints\"));\n let brief_run = store\n .create_run(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n model_name.clone(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"harness engineers evaluating runtime reliability\".to_string(),\n content_hierarchy: vec![\n \"Hero\".to_string(),\n \"Runtime protections\".to_string(),\n \"Observable generation\".to_string(),\n ],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain the website/docs generation harness\",\n \"keyContent\": [\"hero\", \"tool call recovery\", \"preview promotion\"]\n }\n ]),\n visual_direction: \"SaaS style, calm technical confidence, polished spacing\"\n .to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\n \"Workspace already contains a buildable minimal project; do not install packages.\"\n .to_string(),\n ],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"real-deepseek-website\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n model_name.clone(),\n vec![\n ContentSource::readable(\n \"prompt\",\n \"prompt\",\n \"Generate a concise SaaS website for the runtime harness. Use project.write_page for route /. Then run project.build, preview.start, browser.open, browser.screenshot, preview.report_candidate, and run.complete. Do not use package.install and do not rewrite package.json.\",\n ),\n ContentSource::readable(\n \"design-md\",\n \"design_md\",\n \"# Design requirements\\n- SaaS dashboard/operations style\\n- Clear hero, proof points, and operational metrics\\n- Mention JSON tool-call recovery, chunked writes, and preview promotion\\n- Keep the page compact enough for the direct tool budget; prefer project.write_page\\n\",\n ),\n ],\n Some(brief_id),\n None,\n )\n .await;\n let client = OpenAiCompatibleModelClient::new(base_url, api_key, Some(\"deepseek\"))\n .with_streaming(env_flag(\"MODEL_STREAMING\"))\n .with_strict_tools(env_flag(\"MODEL_STRICT_TOOLS\"));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(client), executor);\n\n let results = loop_runner.run(&build_run.id).await.unwrap();\n let run = store.get_run(&build_run.id).await.unwrap();\n let events = store.events(&build_run.id).await;\n let serialized_events = serde_json::to_string(&events).unwrap();\n\n assert!(\n matches!(\n run.status,\n AgentRunStatus::Completed | AgentRunStatus::Partial\n ),\n \"real DeepSeek website E2E should end completed or partial, got {:?}; results={results:?}; events={serialized_events}\",\n run.status\n );\n assert!(\n !serialized_events.contains(\"fs.write requires path\"),\n \"run-167 regression: stream must not misclassify malformed/oversized tool input as fs.write requires path\"\n );\n assert!(\n run.status == AgentRunStatus::Partial\n || events\n .iter()\n .any(|event| { serde_json::to_value(event).unwrap()[\"type\"] == \"preview.updated\" }),\n \"completed real DeepSeek website E2E should promote a preview; events={serialized_events}\"\n );\n if run.status == AgentRunStatus::Partial {\n assert!(\n events.iter().any(|event| {\n let value = serde_json::to_value(event).unwrap();\n value[\"type\"] == \"tool.failed\"\n && value\n .get(\"metadata\")\n .and_then(|metadata| metadata.get(\"errorKind\"))\n .is_some()\n }) || events.iter().any(|event| {\n serde_json::to_value(event).unwrap()[\"type\"] == \"tool.recovery_suggested\"\n }),\n \"partial real DeepSeek E2E should include actionable typed recovery evidence\"\n );\n }\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nfn env_flag(name: &str) -> bool {\n std::env::var(name)\n .ok()\n .is_some_and(|value| value == \"1\" || value.eq_ignore_ascii_case(\"true\"))\n}\n\nfn prepare_minimal_buildable_project(workspace: &std::path::Path) {\n fs::create_dir_all(workspace.join(\"project/scripts\")).unwrap();\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs\")).unwrap();\n fs::write(\n workspace.join(\"state/project.json\"),\n json!({\n \"projectId\": \"real-deepseek-website\",\n \"template\": \"astro-website\",\n \"appRoot\": \"project\"\n })\n .to_string(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/package.json\"),\n serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": {\n \"build\": \"node scripts/build.mjs\"\n }\n }))\n .unwrap(),\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/scripts/build.mjs\"),\n r#\"import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nconst source = readFileSync('src/pages/index.astro', 'utf8');\nconst body = source.replace(/^---[\\s\\S]*?---\\s*/, '');\nmkdirSync('../outputs/build', { recursive: true });\nmkdirSync('dist', { recursive: true });\nwriteFileSync('../outputs/build/build.log', 'Build ok\\n');\nwriteFileSync('dist/index.html', `<!doctype html><html><head><title>Runtime Harness</title></head><body>${body}</body></html>`);\n\"#,\n )\n .unwrap();\n fs::write(\n workspace.join(\"project/src/pages/index.astro\"),\n \"<main><h1>Runtime Harness Placeholder</h1></main>\\n\",\n )\n .unwrap();\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}, "8ad04b7298b77631bedaf51af82cf53708887641": {"t": 1785757457.5780914, "n": 34514, "h": "8e8477ee6566cc40", "c": "use anydesign_runtime::{\n agent_loop::AgentLoop,\n conversation::RuntimeStore,\n model_gateway::{MockModelClient, ModelClient, ModelRequest, ModelResponse, ToolCall},\n permission::{PermissionReason, PermissionResult, PermissionRules},\n tools::sandbox::sandbox_tools,\n tools::{\n control_plane::control_plane_executor,\n runtime::{\n InterruptBehavior, ProgressSink, Tool, ToolContext, ToolError, ToolExecutor, ToolResult,\n },\n },\n types::{AgentEvent, AgentPhase, AgentRunStatus, Brief, ContentSource},\n};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse serde_json::{json, Value};\nuse std::{collections::VecDeque, fs, path::PathBuf, sync::Arc};\nuse tokio::{io::AsyncWriteExt, net::TcpListener, sync::Mutex, task::JoinHandle};\n\n#[tokio::test]\nasync fn build_run_bootstraps_confirmed_brief_into_workspace_before_model_turn() {\n let workspace = unique_temp_dir(\"agent-loop-bootstrap\");\n fs::create_dir_all(workspace.join(\"inputs\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![\"Use the internal brand system.\".to_string()],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![\n ContentSource::readable(\"source-1\", \"prompt\", \"Build the runtime page\"),\n ContentSource::readable(\"source-2\", \"design_md\", \"# Visual rules\"),\n ContentSource {\n id: \"source-unreadable\".to_string(),\n kind: \"attachment_text\".to_string(),\n text: \"should not enter workspace\".to_string(),\n readable: false,\n },\n ],\n Some(brief_id.clone()),\n None,\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\n \"stop after bootstrap assertion\".to_string(),\n )]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let brief_md = fs::read_to_string(workspace.join(\"inputs/brief.md\")).unwrap();\n assert!(brief_md.contains(&format!(\"# Brief {brief_id}\")));\n assert!(brief_md.contains(\"Runtime reliability\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"inputs/design.md\")).unwrap(),\n \"# Visual rules\"\n );\n let content_sources =\n fs::read_to_string(workspace.join(\"inputs/content-sources.json\")).unwrap();\n assert!(content_sources.contains(\"Build the runtime page\"));\n assert!(content_sources.contains(\"# Visual rules\"));\n assert!(!content_sources.contains(\"should not enter workspace\"));\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/tasks.json\")).unwrap(),\n \"[]\"\n );\n assert_eq!(\n fs::read_to_string(workspace.join(\"state/preview.json\")).unwrap(),\n \"{}\"\n );\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.text == \"Workspace inputs prepared for sandbox execution.\"));\n let events = store.events(&build_run.id).await;\n let mut started = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolStarted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n let mut completed = events\n .iter()\n .filter_map(|event| match event {\n AgentEvent::ToolCompleted {\n tool_use_id, tool, ..\n } if tool == \"fs.write\" && tool_use_id.starts_with(\"bootstrap:\") => {\n Some(tool_use_id.clone())\n }\n _ => None,\n })\n .collect::<Vec<_>>();\n started.sort();\n completed.sort();\n assert_eq!(started, completed);\n assert_eq!(started.len(), 5);\n assert!(started.contains(&\"bootstrap:inputs/brief.md\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/content-sources.json\".to_string()));\n assert!(started.contains(&\"bootstrap:inputs/design.md\".to_string()));\n assert!(started.contains(&\"bootstrap:state/tasks.json\".to_string()));\n assert!(started.contains(&\"bootstrap:state/preview.json\".to_string()));\n}\n\n#[tokio::test]\nasync fn bootstrap_workspace_failure_emits_tool_failed_before_run_failed() {\n let store = RuntimeStore::new();\n let brief_run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let brief_id = store\n .write_brief(\n &brief_run.id,\n Brief {\n project_type: \"website\".to_string(),\n audience: \"internal design teams\".to_string(),\n content_hierarchy: vec![\"Runtime reliability\".to_string()],\n page_structure: json!([\n {\n \"title\": \"Home\",\n \"purpose\": \"Explain runtime scope\",\n \"keyContent\": [\"hero\"]\n }\n ]),\n visual_direction: \"precise and calm\".to_string(),\n recommended_template: \"astro-website\".to_string(),\n assumptions: vec![],\n missing_information: vec![],\n },\n )\n .await\n .unwrap();\n let build_run = store\n .create_run_with_context(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n Some(brief_id),\n None,\n )\n .await;\n let executor = ToolExecutor::new(\n vec![Arc::new(RecoverableFsWriteTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(vec![])),\n executor,\n );\n\n loop_runner.run(&build_run.id).await.unwrap();\n\n let run = store.get_run(&build_run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let events = store.events(&build_run.id).await;\n let event_types = events\n .iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let started_index = event_types\n .iter()\n .position(|event| event == \"tool.started\")\n .expect(\"bootstrap fs.write should emit tool.started\");\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"bootstrap fs.write should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed bootstrap should emit run.completed\");\n assert!(started_index < failed_index);\n assert!(failed_index < completed_index);\n assert!(events.iter().any(|event| matches!(\n event,\n AgentEvent::ToolFailed {\n tool,\n tool_use_id,\n recoverable: true,\n ..\n } if tool == \"fs.write\" && tool_use_id == \"bootstrap:inputs/brief.md\"\n )));\n assert!(store\n .conversation_items(\"project-1\")\n .await\n .iter()\n .any(|item| item.kind == \"tool_failed\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"fs.write\"\n && metadata[\"toolUseId\"] == \"bootstrap:inputs/brief.md\"\n && metadata[\"recoverable\"] == true\n })));\n}\n\n#[tokio::test]\nasync fn run_cannot_complete_without_run_complete() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"The brief is ready.\".to_string()),\n ModelResponse::TextOnly(\"But no completion tool was called.\".to_string()),\n ModelResponse::TextOnly(\"Still no completion tool.\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_ne!(run.status, AgentRunStatus::Completed);\n assert_eq!(run.status, AgentRunStatus::Partial);\n}\n\n#[tokio::test]\nasync fn three_consecutive_empty_turns_transition_to_partial() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::TextOnly(\"Thinking\".to_string()),\n ModelResponse::TextOnly(\"Still thinking\".to_string()),\n ModelResponse::TextOnly(\"No tools\".to_string()),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Partial);\n let events = store.events(&run.id).await;\n assert!(events\n .iter()\n .any(|event| serde_json::to_value(event).unwrap()[\"status\"] == \"partial\"));\n}\n\n#[tokio::test]\nasync fn model_error_marks_run_failed() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Brief,\n \"brief\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::Error(\"model unavailable\".to_string())]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n}\n\n#[tokio::test]\nasync fn model_error_after_tool_use_emits_missing_tool_result_before_failure() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCallsThenError {\n calls: vec![ToolCall::new(\"tool-open\", \"safe.pending\", json!({}))],\n error: \"model stream disconnected\".to_string(),\n }]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 1);\n assert_eq!(results[0].tool_use_id, \"tool-open\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"model stream disconnected\"));\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let event_types = store\n .events(&run.id)\n .await\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"missing tool result should emit tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"failed run should emit run.completed\");\n assert!(\n failed_index < completed_index,\n \"missing tool_result must be emitted before failed run completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn fallback_discards_old_tool_attempt_and_continues_next_turn() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCallsThenFallback {\n calls: vec![ToolCall::new(\n \"tool-stale\",\n \"stale.should_not_run\",\n json!({}),\n )],\n reason: \"primary model overloaded\".to_string(),\n },\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Fallback completed the run\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert_eq!(results.len(), 2);\n assert_eq!(results[0].tool_use_id, \"tool-stale\");\n assert!(results[0].is_error);\n assert!(results[0].content[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"primary model overloaded\"));\n assert_eq!(results[1].tool_use_id, \"tool-complete\");\n assert!(!results[1].is_error);\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let events = store.events(&run.id).await;\n let stale_completed = events.iter().any(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolCompleted { tool_use_id, .. } => {\n tool_use_id == \"tool-stale\"\n }\n _ => false,\n });\n assert!(\n !stale_completed,\n \"discarded fallback attempt must not complete\"\n );\n let event_types = events\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let failed_index = event_types\n .iter()\n .position(|event| event == \"tool.failed\")\n .expect(\"discarded attempt should emit synthetic tool.failed\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"fallback run should complete\");\n assert!(\n failed_index < completed_index,\n \"discarded tool_result must land before fallback completion: {event_types:?}\"\n );\n}\n\n#[tokio::test]\nasync fn tool_and_run_events_are_written_to_conversation_items() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-missing\",\n \"missing.tool\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Conversation visible completion\" }),\n )]),\n ]);\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let conversation = store.conversation_items(\"project-1\").await;\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_failed\"\n && item.text.contains(\"missing.tool failed\")\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"missing.tool\" && metadata[\"toolUseId\"] == \"tool-missing\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"tool_completed\"\n && item.text == \"Completed run\"\n && item.metadata.as_ref().is_some_and(|metadata| {\n metadata[\"tool\"] == \"run.complete\" && metadata[\"toolUseId\"] == \"tool-complete\"\n })\n }));\n assert!(conversation.iter().any(|item| {\n item.kind == \"run_completed\"\n && item.text == \"Conversation visible completion\"\n && item\n .metadata\n .as_ref()\n .is_some_and(|metadata| metadata[\"status\"] == \"completed\")\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_sends_messages_and_tool_snapshot_to_model_gateway() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let captured_requests = Arc::new(Mutex::new(Vec::new()));\n let model = RecordingModelClient::new(\n vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-list\",\n \"content.list_sources\",\n json!({}),\n )]),\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Context sent\" }),\n )]),\n ],\n captured_requests.clone(),\n );\n let loop_runner = AgentLoop::new(store.clone(), Arc::new(model));\n\n loop_runner.run(&run.id).await.unwrap();\n\n let requests = captured_requests.lock().await;\n assert_eq!(requests.len(), 2);\n assert_eq!(requests[0].run_id, run.id);\n assert_eq!(requests[0].model, \"internal-balanced\");\n assert_eq!(requests[0].phase, AgentPhase::Export);\n assert_eq!(requests[0].agent_profile, \"export\");\n assert!(requests[0]\n .system_prompt\n .contains(\"AnyDesign runtime export agent\"));\n assert!(requests[0].messages.is_empty());\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"content.list_sources\"));\n assert!(requests[0]\n .tools\n .iter()\n .any(|tool| tool.name == \"run.complete\"\n && tool.input_schema[\"properties\"][\"status\"][\"type\"] == \"string\"));\n assert!(requests[0]\n .deferred_tools\n .iter()\n .all(|tool| !tool.name.is_empty()));\n assert!(requests[1].messages.iter().any(|message| {\n message[\"role\"] == \"tool\"\n && message[\"toolUseId\"] == \"tool-list\"\n && message[\"toolName\"] == \"content.list_sources\"\n }));\n}\n\n#[tokio::test]\nasync fn agent_loop_deterministically_compacts_history_to_workspace_context() {\n let workspace = unique_temp_dir(\"agent-loop-compact\");\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Export,\n \"export\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let mut responses = Vec::new();\n for index in 0..9 {\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n format!(\"tool-missing-{index}\"),\n \"missing.tool\",\n json!({ \"index\": index }),\n )]));\n }\n responses.push(ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Compacted run completed\" }),\n )]));\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(\n store.clone(),\n Arc::new(MockModelClient::new(responses)),\n executor,\n );\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n let context = fs::read_to_string(workspace.join(\"state/context.md\")).unwrap();\n assert!(context.contains(\"# Runtime Context Compact\"));\n assert!(context.contains(\"## Previous Compact\"));\n assert!(context.contains(\"tool-missing-0\"));\n assert!(context.contains(\"tool-missing-6\"));\n assert!(context.contains(\"Compacted messages:\"));\n let checkpoint = store\n .get_checkpoint(run.checkpoint_id.as_deref().unwrap())\n .await\n .unwrap();\n assert!(checkpoint\n .message_window\n .iter()\n .any(|message| message[\"kind\"] == \"compact_summary\"));\n}\n\n#[tokio::test]\nasync fn terminal_tool_error_marks_tool_failed_as_not_recoverable() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-terminal\",\n \"terminal.fail\",\n json!({}),\n )])]);\n let executor = ToolExecutor::new(vec![Arc::new(TerminalFailTool)], PermissionRules::default());\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Failed);\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed { recoverable, .. } => {\n Some(recoverable)\n }\n _ => None,\n })\n .expect(\"terminal tool failure should emit tool.failed\");\n assert!(!failed);\n}\n\n#[tokio::test]\nasync fn shell_non_zero_exit_emits_recoverable_tool_failed_event() {\n let workspace = unique_temp_dir(\"agent-loop-shell\");\n fs::create_dir_all(workspace.join(\"project\")).unwrap();\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-shell\",\n \"shell.run\",\n json!({\n \"argv\": [\"node\", \"-e\", \"process.stderr.write('build failed'); process.exit(5)\"],\n \"cwd\": \"project\"\n }),\n )])]);\n let executor = ToolExecutor::new_with_workspace_root(\n sandbox_tools(),\n PermissionRules::default(),\n &workspace,\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n error, recoverable, ..\n } => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"shell non-zero exit should emit tool.failed\");\n assert!(failed.0.contains(\"status Some(5)\"));\n assert!(failed.0.contains(\"build failed\"));\n assert!(failed.1);\n}\n\n#[tokio::test]\nasync fn continue_interrupt_synthetic_failure_is_not_recoverable_in_events() {\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n store.request_continue_interrupt(&run.id).await;\n let model = MockModelClient::new(vec![\n ModelResponse::ToolCalls(vec![ToolCall::new(\n \"tool-interrupt\",\n \"interrupt.cancel\",\n json!({}),\n )]),\n ModelResponse::Error(\"stop after interrupt assertion\".to_string()),\n ]);\n let executor = ToolExecutor::new(\n vec![Arc::new(InterruptCancelTool)],\n PermissionRules::default(),\n );\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n loop_runner.run(&run.id).await.unwrap();\n\n let failed = store\n .events(&run.id)\n .await\n .into_iter()\n .find_map(|event| match event {\n anydesign_runtime::types::AgentEvent::ToolFailed {\n tool,\n error,\n recoverable,\n ..\n } if tool == \"interrupt.cancel\" => Some((error, recoverable)),\n _ => None,\n })\n .expect(\"synthetic interruption should emit tool.failed\");\n assert!(failed.0.contains(\"new user message\"));\n assert!(!failed.1);\n}\n\n#[tokio::test]\nasync fn tool_driven_build_run_promotes_preview_before_completion() {\n let workspace = unique_temp_dir(\"agent-loop-tool-build\");\n fs::create_dir_all(workspace.join(\"project/src/pages\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/build\")).unwrap();\n fs::create_dir_all(workspace.join(\"outputs/screenshots\")).unwrap();\n fs::create_dir_all(workspace.join(\"state\")).unwrap();\n let (preview_url, _preview_server) = start_preview_server().await;\n let store = RuntimeStore::new();\n let run = store\n .create_run(\n \"project-1\".to_string(),\n AgentPhase::Build,\n \"build\".to_string(),\n \"internal-balanced\".to_string(),\n vec![],\n )\n .await;\n let build_script = \"const fs=require('fs');\\\nfs.mkdirSync('../outputs/build',{recursive:true});\\\nfs.mkdirSync('dist',{recursive:true});\\\nfs.writeFileSync('../outputs/build/build.log','Build ok\\\\n');\\\nfs.writeFileSync('dist/index.html','<!doctype html><title>ok</title>');\";\n let model = MockModelClient::new(vec![ModelResponse::ToolCalls(vec![\n ToolCall::new(\n \"tool-rebuilding\",\n \"preview.rebuilding\",\n json!({ \"previousVersionId\": Value::Null }),\n ),\n ToolCall::new(\n \"tool-package\",\n \"fs.write\",\n json!({\n \"path\": \"project/package.json\",\n \"text\": serde_json::to_string_pretty(&json!({\n \"type\": \"module\",\n \"scripts\": { \"build\": \"node -e \\\"console.log('build')\\\"\" }\n })).unwrap()\n }),\n ),\n ToolCall::new(\n \"tool-index\",\n \"fs.write\",\n json!({ \"path\": \"project/src/pages/index.astro\", \"text\": \"<h1>Design runtime</h1>\" }),\n ),\n ToolCall::new(\n \"tool-build\",\n \"shell.run\",\n json!({ \"argv\": [\"node\", \"-e\", build_script], \"cwd\": \"project\" }),\n ),\n ToolCall::new(\n \"tool-preview\",\n \"preview.start\",\n json!({ \"url\": preview_url, \"port\": 4321 }),\n ),\n ToolCall::new(\n \"tool-browser\",\n \"browser.open\",\n json!({ \"url\": \"http://127.0.0.1:4321\" }),\n ),\n ToolCall::new(\n \"tool-shot\",\n \"browser.screenshot\",\n json!({ \"screenshotId\": \"shot-tool-build\", \"blank\": false }),\n ),\n ToolCall::new(\n \"tool-candidate\",\n \"preview.report_candidate\",\n json!({\n \"url\": preview_url,\n \"screenshotId\": \"shot-tool-build\",\n \"sourceSnapshotUri\": \"file:///workspace/outputs/build/source-snapshot.txt\"\n }),\n ),\n ToolCall::new(\n \"tool-complete\",\n \"run.complete\",\n json!({ \"status\": \"completed\", \"summary\": \"Astro preview promoted\" }),\n ),\n ])]);\n let executor = control_plane_executor().with_workspace_root(&workspace);\n let loop_runner = AgentLoop::with_tool_executor(store.clone(), Arc::new(model), executor);\n\n let results = loop_runner.run(&run.id).await.unwrap();\n\n assert!(results.iter().all(|result| !result.is_error));\n let run = store.get_run(&run.id).await.unwrap();\n assert_eq!(run.status, AgentRunStatus::Completed);\n assert!(run.output_version_id.is_some());\n assert!(workspace.join(\"project/dist/index.html\").exists());\n\n let event_types = store\n .events(&run.id)\n .await\n .into_iter()\n .map(|event| {\n serde_json::to_value(event).unwrap()[\"type\"]\n .as_str()\n .unwrap()\n .to_string()\n })\n .collect::<Vec<_>>();\n let updated_index = event_types\n .iter()\n .position(|event| event == \"preview.updated\")\n .expect(\"preview.updated should be emitted\");\n let completed_index = event_types\n .iter()\n .position(|event| event == \"run.completed\")\n .expect(\"run.completed should be emitted\");\n assert!(\n updated_index < completed_index,\n \"preview.updated must be emitted before run.completed: {event_types:?}\"\n );\n}\n\nstruct TerminalFailTool;\n\n#[async_trait]\nimpl Tool for TerminalFailTool {\n fn name(&self) -> &'static str {\n \"terminal.fail\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Terminal(\n \"sandbox channel disconnected\".to_string(),\n ))\n }\n}\n\nstruct RecoverableFsWriteTool;\n\n#[async_trait]\nimpl Tool for RecoverableFsWriteTool {\n fn name(&self) -> &'static str {\n \"fs.write\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Err(ToolError::Recoverable(\n \"bootstrap write denied by test\".to_string(),\n ))\n }\n}\n\nstruct InterruptCancelTool;\n\n#[async_trait]\nimpl Tool for InterruptCancelTool {\n fn name(&self) -> &'static str {\n \"interrupt.cancel\"\n }\n\n fn input_schema(&self) -> Value {\n json!({ \"type\": \"object\" })\n }\n\n fn interrupt_behavior(&self) -> InterruptBehavior {\n InterruptBehavior::Cancel\n }\n\n async fn check_permission(&self, input: &Value, _ctx: &ToolContext) -> PermissionResult {\n PermissionResult::Allow {\n updated_input: input.clone(),\n reason: PermissionReason::Other {\n reason: \"test allow\".to_string(),\n },\n }\n }\n\n async fn call(\n &self,\n _input: Value,\n _ctx: ToolContext,\n _progress: ProgressSink,\n ) -> Result<ToolResult, ToolError> {\n Ok(ToolResult::ok(json!({ \"shouldNotRun\": true })))\n }\n}\n\n#[derive(Debug, Clone)]\nstruct RecordingModelClient {\n responses: Arc<Mutex<VecDeque<ModelResponse>>>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n}\n\nimpl RecordingModelClient {\n fn new(\n responses: Vec<ModelResponse>,\n requests: Arc<Mutex<Vec<ModelRequest>>>,\n ) -> RecordingModelClient {\n RecordingModelClient {\n responses: Arc::new(Mutex::new(VecDeque::from(responses))),\n requests,\n }\n }\n}\n\n#[async_trait]\nimpl ModelClient for RecordingModelClient {\n async fn next_response(&self, request: ModelRequest) -> anyhow::Result<ModelResponse> {\n self.requests.lock().await.push(request);\n self.responses\n .lock()\n .await\n .pop_front()\n .ok_or_else(|| anyhow!(\"recording model response queue exhausted\"))\n }\n}\n\nfn unique_temp_dir(prefix: &str) -> PathBuf {\n let path = std::env::temp_dir().join(format!(\n \"{prefix}-{}\",\n std::time::SystemTime::now()\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap()\n .as_nanos()\n ));\n fs::create_dir_all(&path).unwrap();\n path\n}\n\nasync fn start_preview_server() -> (String, JoinHandle<()>) {\n let listener = TcpListener::bind(\"127.0.0.1:0\").await.unwrap();\n let addr = listener.local_addr().unwrap();\n let handle = tokio::spawn(async move {\n loop {\n let Ok((mut stream, _)) = listener.accept().await else {\n break;\n };\n let _ = stream\n .write_all(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok\")\n .await;\n }\n });\n (format!(\"http://{addr}/candidate\"), handle)\n}\n"}}, "infra/provider-gateway/model-resources.deepseek-v4-pro.json": {"3b43b5e9cf9e581536a6312c6967d8376dc99c3f": {"t": 1785746295.779362, "n": 1851, "h": "2a2e0fb6236e904c", "c": "{\n \"listen\": \"0.0.0.0:9000\",\n \"databaseUrl\": \"sqlite:/var/lib/provider-gateway/gateway.db\",\n \"resources\": [\n {\n \"schemaVersion\": \"model-resource@1\",\n \"id\": \"deepseek-v4-pro\",\n \"displayName\": \"DeepSeek V4 Pro\",\n \"kind\": \"openai_compatible\",\n \"enabled\": true,\n \"revision\": 4,\n \"endpoint\": {\n \"baseUrl\": \"https://api.deepseek.com\",\n \"chatCompletionsPath\": \"/v1/chat/completions\"\n },\n \"auth\": {\n \"type\": \"bearer\",\n \"secretRef\": \"file:/var/run/secrets/deepseek/api-key\"\n },\n \"physicalModel\": \"deepseek-v4-pro\",\n \"capabilities\": {\n \"toolCalls\": true,\n \"strictToolSchema\": true,\n \"streaming\": false,\n \"vision\": false,\n \"visionInput\": false,\n \"supportedImageMediaTypes\": [],\n \"maxImageBytes\": 0,\n \"maxImageCount\": 0\n },\n \"defaults\": {\n \"requestTimeoutMs\": 120000,\n \"maxAttempts\": 3,\n \"maxConcurrentRequests\": 4,\n \"temperature\": 0.2\n }\n }\n ],\n \"policies\": [\n {\n \"schemaVersion\": \"model-selection-policy@1\",\n \"id\": \"local-deepseek-v4-pro-default\",\n \"revision\": 4,\n \"scope\": {\n \"workspaceIds\": [],\n \"projectIds\": []\n },\n \"appliesTo\": {\n \"phases\": [\"brief\", \"build\", \"edit\", \"review\", \"repair\"],\n \"agentProfiles\": []\n },\n \"candidates\": [\n {\n \"modelResourceId\": \"deepseek-v4-pro\",\n \"priority\": 10,\n \"weight\": 100\n }\n ],\n \"directSelection\": {\n \"allowedModelResourceIds\": [\"deepseek-v4-pro\"]\n },\n \"automaticSwitch\": {\n \"enabled\": false,\n \"allowedReasons\": [],\n \"maxModelSwitchesPerTurn\": 0\n },\n \"limits\": {\n \"maxConcurrentTurns\": 4,\n \"dailyInputTokens\": 100000000\n }\n }\n ]\n}\n"}, "6d40434e9bca26c87bc6f04f4c832edb8b985471": {"t": 1785757430.8196414, "n": 1851, "h": "2a2e0fb6236e904c", "c": "{\n \"listen\": \"0.0.0.0:9000\",\n \"databaseUrl\": \"sqlite:/var/lib/provider-gateway/gateway.db\",\n \"resources\": [\n {\n \"schemaVersion\": \"model-resource@1\",\n \"id\": \"deepseek-v4-pro\",\n \"displayName\": \"DeepSeek V4 Pro\",\n \"kind\": \"openai_compatible\",\n \"enabled\": true,\n \"revision\": 4,\n \"endpoint\": {\n \"baseUrl\": \"https://api.deepseek.com\",\n \"chatCompletionsPath\": \"/v1/chat/completions\"\n },\n \"auth\": {\n \"type\": \"bearer\",\n \"secretRef\": \"file:/var/run/secrets/deepseek/api-key\"\n },\n \"physicalModel\": \"deepseek-v4-pro\",\n \"capabilities\": {\n \"toolCalls\": true,\n \"strictToolSchema\": true,\n \"streaming\": false,\n \"vision\": false,\n \"visionInput\": false,\n \"supportedImageMediaTypes\": [],\n \"maxImageBytes\": 0,\n \"maxImageCount\": 0\n },\n \"defaults\": {\n \"requestTimeoutMs\": 120000,\n \"maxAttempts\": 3,\n \"maxConcurrentRequests\": 4,\n \"temperature\": 0.2\n }\n }\n ],\n \"policies\": [\n {\n \"schemaVersion\": \"model-selection-policy@1\",\n \"id\": \"local-deepseek-v4-pro-default\",\n \"revision\": 4,\n \"scope\": {\n \"workspaceIds\": [],\n \"projectIds\": []\n },\n \"appliesTo\": {\n \"phases\": [\"brief\", \"build\", \"edit\", \"review\", \"repair\"],\n \"agentProfiles\": []\n },\n \"candidates\": [\n {\n \"modelResourceId\": \"deepseek-v4-pro\",\n \"priority\": 10,\n \"weight\": 100\n }\n ],\n \"directSelection\": {\n \"allowedModelResourceIds\": [\"deepseek-v4-pro\"]\n },\n \"automaticSwitch\": {\n \"enabled\": false,\n \"allowedReasons\": [],\n \"maxModelSwitchesPerTurn\": 0\n },\n \"limits\": {\n \"maxConcurrentTurns\": 4,\n \"dailyInputTokens\": 100000000\n }\n }\n ]\n}\n"}, "a09795d62ca4e0ffe42ed2c46956a68cc71771a5": {"t": 1785757432.1283627, "n": 1841, "h": "c0fc04e83d136e1b", "c": "{\n \"listen\": \"0.0.0.0:9000\",\n \"databaseUrl\": \"sqlite:/var/lib/provider-gateway/gateway.db\",\n \"resources\": [\n {\n \"schemaVersion\": \"model-resource@1\",\n \"id\": \"deepseek-v4-pro\",\n \"displayName\": \"DeepSeek V4 Pro\",\n \"kind\": \"openai_compatible\",\n \"enabled\": true,\n \"revision\": 4,\n \"endpoint\": {\n \"baseUrl\": \"https://api.deepseek.com\",\n \"chatCompletionsPath\": \"/v1/chat/completions\"\n },\n \"auth\": {\n \"type\": \"bearer\",\n \"secretRef\": \"file:/var/run/secrets/deepseek/api-key\"\n },\n \"physicalModel\": \"deepseek-v4-pro\",\n \"capabilities\": {\n \"toolCalls\": true,\n \"strictToolSchema\": true,\n \"streaming\": false,\n \"vision\": false,\n \"visionInput\": false,\n \"supportedImageMediaTypes\": [],\n \"maxImageBytes\": 0,\n \"maxImageCount\": 0\n },\n \"defaults\": {\n \"requestTimeoutMs\": 120000,\n \"maxAttempts\": 3,\n \"maxConcurrentRequests\": 4,\n \"temperature\": 0.2\n }\n }\n ],\n \"policies\": [\n {\n \"schemaVersion\": \"model-selection-policy@1\",\n \"id\": \"local-deepseek-v4-pro-default\",\n \"revision\": 3,\n \"scope\": {\n \"workspaceIds\": [],\n \"projectIds\": []\n },\n \"appliesTo\": {\n \"phases\": [\"brief\", \"build\", \"edit\", \"repair\"],\n \"agentProfiles\": []\n },\n \"candidates\": [\n {\n \"modelResourceId\": \"deepseek-v4-pro\",\n \"priority\": 10,\n \"weight\": 100\n }\n ],\n \"directSelection\": {\n \"allowedModelResourceIds\": [\"deepseek-v4-pro\"]\n },\n \"automaticSwitch\": {\n \"enabled\": false,\n \"allowedReasons\": [],\n \"maxModelSwitchesPerTurn\": 0\n },\n \"limits\": {\n \"maxConcurrentTurns\": 4,\n \"dailyInputTokens\": 100000000\n }\n }\n ]\n}\n"}, "a21326772131ae0348f89edf1d44162f84f3e7f3": {"t": 1785757433.6357567, "n": 1715, "h": "7353814b8311619b", "c": "{\n \"listen\": \"0.0.0.0:9000\",\n \"databaseUrl\": \"sqlite:/var/lib/provider-gateway/gateway.db\",\n \"resources\": [\n {\n \"schemaVersion\": \"model-resource@1\",\n \"id\": \"deepseek-v4-pro\",\n \"displayName\": \"DeepSeek V4 Pro\",\n \"kind\": \"openai_compatible\",\n \"enabled\": true,\n \"revision\": 4,\n \"endpoint\": {\n \"baseUrl\": \"https://api.deepseek.com\",\n \"chatCompletionsPath\": \"/v1/chat/completions\"\n },\n \"auth\": {\n \"type\": \"bearer\",\n \"secretRef\": \"file:/var/run/secrets/deepseek/api-key\"\n },\n \"physicalModel\": \"deepseek-v4-pro\",\n \"capabilities\": {\n \"toolCalls\": true,\n \"strictToolSchema\": true,\n \"streaming\": false,\n \"vision\": false\n },\n \"defaults\": {\n \"requestTimeoutMs\": 120000,\n \"maxAttempts\": 3,\n \"maxConcurrentRequests\": 4,\n \"temperature\": 0.2\n }\n }\n ],\n \"policies\": [\n {\n \"schemaVersion\": \"model-selection-policy@1\",\n \"id\": \"local-deepseek-v4-pro-default\",\n \"revision\": 3,\n \"scope\": {\n \"workspaceIds\": [],\n \"projectIds\": []\n },\n \"appliesTo\": {\n \"phases\": [\"brief\", \"build\", \"edit\", \"repair\"],\n \"agentProfiles\": []\n },\n \"candidates\": [\n {\n \"modelResourceId\": \"deepseek-v4-pro\",\n \"priority\": 10,\n \"weight\": 100\n }\n ],\n \"directSelection\": {\n \"allowedModelResourceIds\": [\"deepseek-v4-pro\"]\n },\n \"automaticSwitch\": {\n \"enabled\": false,\n \"allowedReasons\": [],\n \"maxModelSwitchesPerTurn\": 0\n },\n \"limits\": {\n \"maxConcurrentTurns\": 4,\n \"dailyInputTokens\": 100000000\n }\n }\n ]\n}\n"}, "01e2ccccc9bdefbe7e8f0e163112ccf898d58350": {"t": 1785757434.9253886, "n": 1746, "h": "8d942a43fc7aff48", "c": "{\n \"listen\": \"0.0.0.0:9000\",\n \"databaseUrl\": \"sqlite:/var/lib/provider-gateway/gateway.db\",\n \"resources\": [\n {\n \"schemaVersion\": \"model-resource@1\",\n \"id\": \"deepseek-v4-pro\",\n \"displayName\": \"DeepSeek V4 Pro\",\n \"kind\": \"openai_compatible\",\n \"enabled\": true,\n \"revision\": 4,\n \"endpoint\": {\n \"baseUrl\": \"https://api.deepseek.com\",\n \"chatCompletionsPath\": \"/v1/chat/completions\"\n },\n \"auth\": {\n \"type\": \"bearer\",\n \"secretRef\": \"file:/var/run/secrets/deepseek/api-key\"\n },\n \"physicalModel\": \"deepseek-v4-pro\",\n \"capabilities\": {\n \"toolCalls\": true,\n \"strictToolSchema\": true,\n \"streaming\": false,\n \"vision\": false\n },\n \"defaults\": {\n \"requestTimeoutMs\": 120000,\n \"maxAttempts\": 3,\n \"maxConcurrentRequests\": 4,\n \"temperature\": 0.2\n }\n }\n ],\n \"policies\": [\n {\n \"schemaVersion\": \"model-selection-policy@1\",\n \"id\": \"local-deepseek-v4-pro-default\",\n \"revision\": 3,\n \"scope\": {\n \"organizationIds\": [],\n \"workspaceIds\": [],\n \"projectIds\": []\n },\n \"appliesTo\": {\n \"phases\": [\"brief\", \"build\", \"edit\", \"repair\"],\n \"agentProfiles\": []\n },\n \"candidates\": [\n {\n \"modelResourceId\": \"deepseek-v4-pro\",\n \"priority\": 10,\n \"weight\": 100\n }\n ],\n \"directSelection\": {\n \"allowedModelResourceIds\": [\"deepseek-v4-pro\"]\n },\n \"automaticSwitch\": {\n \"enabled\": false,\n \"allowedReasons\": [],\n \"maxModelSwitchesPerTurn\": 0\n },\n \"limits\": {\n \"maxConcurrentTurns\": 4,\n \"dailyInputTokens\": 100000000\n }\n }\n ]\n}\n"}}, "README.md": {"a24adf8e130699fc43c83d31b7ccee474ff4057f": {"t": 1786068441.6604815, "n": 12477, "h": "502de1ae7883caaf", "c": "# zeronDesign\n\n## Runtime model providers\n\nRuntime and all formal real-provider gates use the internal Provider Gateway:\n\n```bash\nMODEL_PROVIDER=internal_gateway\nMODEL_GATEWAY_URL=http://localhost:9000\nMODEL_GATEWAY_AUTH_TOKEN_FILE=/path/to/runtime-workload-token\nAGENT_MODEL=deepseek-v4-pro\n```\n\nRuns submit only `modelResourceId`. Provider endpoint, physical model, capabilities and Provider API Key are resolved\nby the Gateway from the versioned Model Resource. Configure or rotate the Provider Secret through the Gateway Admin\nAPI or a mounted Secret; never pass it to Runtime or a Run request.\n\nThe direct OpenAI-compatible adapters below are retained only for isolated connectivity diagnostics and unit coverage.\nThey do not qualify as canary, paired-cohort, or release evidence:\n\n```bash\n# DeepSeek\nMODEL_PROVIDER=deepseek\nDEEPSEEK_API_KEY=...\n# optional; defaults to deepseek-chat when MODEL_PROVIDER=deepseek\nDEEPSEEK_MODEL=deepseek-chat\n# optional; defaults to https://api.deepseek.com\nDEEPSEEK_BASE_URL=https://api.deepseek.com\n# optional; disabled by default and required for multi-turn tool calling\nDEEPSEEK_THINKING_MODE=disabled\n\n# Kimi / Moonshot global\nMODEL_PROVIDER=kimi_global\nKIMI_API_KEY=...\n# optional; defaults to https://api.moonshot.ai/v1\nKIMI_BASE_URL=https://api.moonshot.ai/v1\n\n# Kimi / Moonshot China\nMODEL_PROVIDER=kimi_cn\nKIMI_CN_API_KEY=...\n# optional; defaults to https://api.moonshot.cn/v1\nKIMI_CN_BASE_URL=https://api.moonshot.cn/v1\n```\n\n`KIMI_API_KEY` can also be supplied as `MOONSHOT_API_KEY`. `kimi_cn` falls back to\n`KIMI_API_KEY` / `MOONSHOT_API_KEY` when `KIMI_CN_API_KEY` is not set.\n\nThe direct DeepSeek adapter sends `thinking.type=disabled` by default so assistant\ntool calls can be replayed safely on the next turn. `DEEPSEEK_THINKING_MODE=enabled`\nis supported only for requests without tool definitions; Runtime rejects an\nenabled-thinking tool request before any network call.\n\n`AGENT_MODEL` or `MODEL_NAME` can override the model used by `/runs` for any\nprovider. The default remains `internal-balanced` for the internal gateway.\n\nReal governed regression checks are gated behind ignored tests, so a live Gateway is never required by the default\nsuite. To rerun the Generation Context regression against an already configured Model Resource:\n\n```bash\nMODEL_GATEWAY_URL=http://localhost:9000 \\\nMODEL_GATEWAY_AUTH_TOKEN_FILE=/path/to/runtime-workload-token \\\nDEEPSEEK_E2E_MODEL=deepseek-v4-pro \\\nRUNTIME_PROVIDER_APPROVAL_ID=<approval-event-id> \\\ncargo test --manifest-path services/runtime/Cargo.toml \\\n --test http_api real_provider_generation_context_greenfield_build \\\n -- --ignored --nocapture\n```\n\nOptional OpenAI-compatible safeguards:\n\n```bash\nMODEL_STREAMING=true\nMODEL_STRICT_TOOLS=true\n```\n\nRuntime workers use an explicit 8 MiB thread stack. Override it only when required:\n\n```bash\nRUNTIME_WORKER_STACK_BYTES=8388608\n```\n\nThe value must be between 2 MiB and 64 MiB. Startup logs record the effective\nstack size, worker count, and theoretical aggregate stack reservation.\n\nFor local end-to-end publication tests, Runtime can replace the external release\npackager, registry, Kubernetes runtime, DNS, and external probe with deterministic\nprocess-local fixtures:\n\n```bash\nRUNTIME_POLICY_PROFILE=local-e2e\nWORK_RUNTIME_BACKEND=e2e_fixture\nANYDESIGN_ENABLE_E2E_PUBLICATION_BACKEND=1\n```\n\nBoth the LocalE2E policy and the explicit enable flag are required. Startup fails\nif fixture publication is selected in production or without the flag. Fixture\nrelease records carry `packagingMode=e2e_fixture`, runtime states carry\n`probeMode=e2e_fixture`, and all synthetic identities are fixture-prefixed.\nThey are not valid Kubernetes or external supply-chain evidence. A completed\nfixture workflow exposes the promoted artifact through\n`{RUNTIME_PUBLIC_BASE_URL}/artifacts/{projectId}/current`.\n\nOptional MCP servers are Runtime-owned and must be explicitly scoped to projects.\nRun requests select only configured `mcpServerNames`; credentials stay in the\nRuntime environment and are persisted only as references:\n\n```bash\nexport GITHUB_MCP_TOKEN='...'\nexport RUNTIME_MCP_SERVERS_JSON='[\n {\n \"serverId\": \"github-primary\",\n \"serverName\": \"github\",\n \"endpoint\": \"https://mcp.example.com/rpc\",\n \"credentialRef\": \"env:GITHUB_MCP_TOKEN\",\n \"permissionRevision\": 1,\n \"allowedProjectIds\": [\"project-123\"]\n }\n]'\nexport ANYDESIGN_CONTEXT_SUMMARY_MODE=enforce\nexport ANYDESIGN_TOOL_CATALOG_MODE=enforce\nexport ANYDESIGN_MCP_MODE=enforce\n```\n\nMCP tools enter the model context through `tool.search` and `tool.load`. Their\nschema hash and permission revision are frozen into the Run checkpoint.\nEach Stage 3 capability defaults to `shadow`; switching its independent mode to\n`enforce` activates semantic summary injection, catalog drift rejection, or MCP\ntool exposure respectively.\n\nRead-only AgentTasks are selected through versioned Agent Definitions. The\ninitial built-ins are `codebase-explorer`, `implementation-planner`,\n`evidence-reviewer`, and `research-agent`. Each definition freezes its prompt,\nrevision, content hash, model, result contract, budget, context mode, and\nread-only tool intersection into the durable task record. A child can only\nreduce the tools already visible to the parent; it cannot load tools, create\nanother agent, run shell commands, mutate the workspace, publish, or complete\nthe parent run. MCP tools additionally require an explicit `readOnlyHint`.\n\nRoll out the Definition policy independently:\n\n```bash\n# generic Stage 4 AgentTask behavior\nexport ANYDESIGN_AGENT_DEFINITIONS_MODE=off\n\n# resolve and compare definitions, but execute the generic policy\nexport ANYDESIGN_AGENT_DEFINITIONS_MODE=shadow\n\n# enforce the selected definition, role-specific result contract and budget\nexport ANYDESIGN_AGENT_DEFINITIONS_MODE=enforce\n\n# optional staged rollout: only these known definitions use enforce;\n# other known definitions remain on the shadow path, while unknown agents\n# are still rejected\nexport ANYDESIGN_AGENT_DEFINITIONS_ENFORCE_ALLOWLIST_JSON='[\"codebase-explorer\"]'\n```\n\nThe default is `shadow`. If the enforce allowlist is omitted, `enforce` applies\nto all built-ins for backward compatibility. An explicit `[]` shadows all\nknown definitions while retaining fail-closed rejection for unknown agents.\nThe allowlist accepts only unique, exact built-in `agentType` values and invalid\nconfiguration prevents Runtime startup. New integrations should call\n`agent.task_start` with\n`agentType`; the former `role` field remains a compatibility input for one\nrelease cycle. Omitting `allowedTools` uses the parent/definition intersection\nin enforced mode; in that mode, an explicit empty array creates a no-tool task.\n\nThe k3d startup/rollback rehearsal exercises Explorer-only, Planner/Reviewer,\nResearch, `off`, and restored `enforce` on one image, then restores the original\ndeployment configuration:\n\n```bash\nAGENT_DEFINITION_ROLLOUT_RUN_ID=<unique-run-id> \\\ninfra/generation-reliability/run-agent-definition-rollout-rehearsal.sh\n```\n\nIts report deliberately sets `releaseGateComplete=false`; deployment health\ndoes not replace the governed internal task batch or observation window.\n\nReal Create/Edit evidence can be reduced to a secret-free AgentTask functional\naudit from the persisted event stream:\n\n```bash\nnode infra/generation-reliability/audit-agent-task-evidence.mjs \\\n --events <run-events.ndjson> \\\n --mode shadow \\\n --minimum-tasks 2 \\\n --out <agent-task-functional-audit.json>\n```\n\nThe audit requires resolved, started, terminal, budget-release, parent\n`agent.task_start`/`agent.task_result`, zero policy/recovery failures, and final\nactive count zero. To include an AgentTask-governed Edit in the existing real\nProvider Warm Edit suite, set\n`GENERATION_REAL_DRAFT_WARM_EDIT_CANARY=1`,\n`GENERATION_REAL_REQUIRED_AGENT_TASK_COUNT=2`, and\n`GENERATION_REAL_AGENT_DEFINITION_MODE=shadow`. The runner adds the bounded\nExplorer/Planner instruction and fails the Edit evidence if the functional\nloop is incomplete. When Draft acceptance is enabled, the suite verifies the\nbase Create Draft before invoking the Edit runner; the Edit then owns its HMR,\nmarker, and AgentTask acceptance. This prevents a missing Create requirement\nfrom being misattributed to a later Edit and avoids spending an Edit call when\nthe base Draft is already invalid.\n\nThe deterministic validation suite includes the frozen 20-task App Builder\ncorpus and bounded fan-out/recovery regressions:\n\n```bash\ncd services/runtime\ncargo test --test agent_definition_integration\ncargo test --test agent_usability_eval\ncargo test --test agent_performance\n```\n\nAfter real Control/Treatment runs and two-person blind review, validate the\ngoverned 20-task report with:\n\n```bash\nnode scripts/agent-ab-review.mjs prepare \\\n --corpus tests/fixtures/agent_tasks/offline-corpus-v1.json \\\n --observations target/agent-eval-reports/ab-observations.json \\\n --out target/agent-eval-reports/ab-blind-review-packet.json\n\nnode scripts/agent-ab-review.mjs finalize \\\n --corpus tests/fixtures/agent_tasks/offline-corpus-v1.json \\\n --observations target/agent-eval-reports/ab-observations.json \\\n --review target/agent-eval-reports/reviewer-a.json \\\n --review target/agent-eval-reports/reviewer-b.json \\\n --out target/agent-eval-reports/governed-ab-results.json\n\nAGENT_EVAL_AB_RESULTS_PATH=<governed-ab-results.json> \\\nAGENT_EVAL_AB_GATE_REPORT_PATH=target/agent-eval-reports/governed-ab-gate.json \\\ncargo test --test agent_usability_eval governed_offline_ab -- --ignored --nocapture\n```\n\nThe observations and reviewer input contracts are\n`agent-offline-ab-observations@1` and `agent-offline-ab-review@1`. The prepared\npacket replaces case IDs with run-scoped hashes and excludes arm identity,\nacceptance outcomes, and parent-read counts. Split useful judgments require an\nexplicit `agent-offline-ab-adjudications@1` file passed with\n`--adjudications`. Reviewer hashes must come from random, non-personal\npseudonyms rather than names or email addresses. The merger and final Rust gate\nnormalize hash case, require distinct identities and pin one reviewer panel\nacross all 20 cases; raw reviewer identity is never accepted.\nThe merged result retains the governed `evaluationRunId`. A passing Rust Gate\natomically writes `agent-offline-ab-gate-report@1`, binding the measured\nthresholds to the SHA-256 of that exact result file.\n\nThe real-model test is ignored by default. Run it only with an approved profile\nand configured model credentials; it exercises all four built-ins with no\nworkspace tools:\n\n```bash\nAGENT_EVAL_REAL_MODEL=1 \\\nAGENT_EVAL_PROFILE=<approved-profile> \\\nAGENT_EVAL_EXPECTED_MODEL=<approved-model-id> \\\nAGENT_EVAL_FIXTURE_SET=smoke \\\nAGENT_EVAL_REPORT_PATH=target/agent-eval-reports/smoke.json \\\ncargo test --test agent_usability_eval real_model -- --ignored --nocapture\n```\n\nProject commands remain native sandbox tools: use `shell.run` for bounded\nforeground work, or `shell.start`, `shell.status`, and `shell.cancel` for a\ncheckpointed background `CommandTask`. Both paths reject host paths and keep\nexecution inside the project workspace.\n\nBuild/Edit runs use a hybrid runtime flow: template lifecycle tools initialize\nand build the project deterministically, while the agent still uses the general\nvibecoding tools (`fs.*`, `package.install`, diagnostics, browser checks) for\nsource, content, and style generation. The default path should avoid interactive\n`npm`/`npx` scaffold commands; official scaffold commands are allowed only when\nwrapped by a non-interactive runtime tool with the same permission and audit\nchecks.\n\n`package.install` defaults to the configured internal registry/proxy. Local E2E\ndebugging may explicitly opt into the public npm registry, but production-like\nsandboxes keep public internet access denied by policy.\n\nRuntime policy defaults to `production`. Public registry access and other local\nE2E exceptions require an explicit `local-e2e` test/admin configuration and must\nbe audited. Preview startup uses the locked app root from `state/project.json`,\nso build and preview operate on the same project tree.\n\nWhen the runtime runs on the desktop host while sandboxes run in k3d, the default\nworkspace channel DNS name (`*.svc.cluster.local`) is not resolvable from the\nhost. For local E2E debugging, port-forward a sandbox pod's channel port and\nstart the runtime with:\n\n```bash\nSANDBOX_CHANNEL_HOST_OVERRIDE=127.0.0.1\nSANDBOX_CHANNEL_PORT_OVERRIDE=<local-port>\n```\n"}}}}