Skip to main content

nyx_agent_ai/tasks/
project_setup.rs

1//! Project setup agent task.
2//!
3//! This agent inspects a local project repository, tries to understand
4//! the development workflow, and emits a Nyx Agent launch profile. It is
5//! intentionally separate from auth setup: this task makes the app
6//! startable; the auth task then learns roles and sessions against the
7//! running app.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use nyx_agent_types::agent::{
14    classify_tool_use, AgentResult, AgentTask, AgentTraceMetrics, AiError, Budget, BudgetKind,
15    ExtractedAgentResult,
16};
17use nyx_agent_types::event::EventSink;
18use nyx_agent_types::product::{
19    LaunchHealthCheck, LaunchStep, ProjectLaunchProfile, ProjectLaunchProfileInput,
20};
21use serde_json::Value;
22
23use crate::runtime::AiRuntime;
24use crate::tasks::structured_output::{json_values_from_text, optional_string, string_array};
25
26pub const PROJECT_SETUP_PROMPT_VERSION: &str = "phase25.project_setup.v1";
27pub const DEFAULT_PROJECT_SETUP_RUN_CAP_USD_MICROS: i64 = 3_000_000;
28pub const DEFAULT_PROJECT_SETUP_MAX_TURNS: u32 = 24;
29pub const DEFAULT_PROJECT_SETUP_TOOL_NAMES: &[&str] =
30    &["Read", "Grep", "Bash", "record_project_setup"];
31
32#[derive(Debug, Clone)]
33pub struct ProjectSetupScope {
34    pub project_id: String,
35    pub project_name: String,
36    pub task_id: String,
37    pub target_base_url: Option<String>,
38    pub workspace_roots: Vec<String>,
39    pub existing_launch_profile: Option<ProjectLaunchProfile>,
40    pub run_cap_usd_micros: i64,
41    pub max_turns: u32,
42}
43
44impl ProjectSetupScope {
45    pub fn new(project_id: impl Into<String>, project_name: impl Into<String>) -> Self {
46        let project_id = project_id.into();
47        Self {
48            task_id: format!("project-setup-{project_id}"),
49            project_id,
50            project_name: project_name.into(),
51            target_base_url: None,
52            workspace_roots: Vec::new(),
53            existing_launch_profile: None,
54            run_cap_usd_micros: DEFAULT_PROJECT_SETUP_RUN_CAP_USD_MICROS,
55            max_turns: DEFAULT_PROJECT_SETUP_MAX_TURNS,
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct ProjectSetupOutcome {
62    pub profile: ProjectLaunchProfileInput,
63    pub summary: String,
64    pub checks: Vec<String>,
65    pub warnings: Vec<String>,
66    pub final_message: String,
67    pub turns: u32,
68    pub spent_usd_micros: i64,
69    pub prompt_version: String,
70    pub metrics: AgentTraceMetrics,
71}
72
73pub async fn run<R: AiRuntime + ?Sized>(
74    runtime: &R,
75    scope: &ProjectSetupScope,
76    sink: EventSink,
77) -> Result<ProjectSetupOutcome, AiError> {
78    let task = build_agent_task(scope);
79    let budget = Budget {
80        run_id: scope.project_id.clone(),
81        kind: BudgetKind::AgentLoop,
82        cap_usd_micros: scope.run_cap_usd_micros,
83    };
84    let result = runtime.agent_loop(task, budget, sink).await?;
85    lift_agent_result(scope, result)
86}
87
88fn build_agent_task(scope: &ProjectSetupScope) -> AgentTask {
89    let workspace_roots = render_list(&scope.workspace_roots, "(no repo workspace roots found)");
90    let target = scope.target_base_url.as_deref().unwrap_or("(not set)");
91    let existing = scope
92        .existing_launch_profile
93        .as_ref()
94        .and_then(|profile| serde_json::to_string_pretty(profile).ok())
95        .unwrap_or_else(|| "(none)".to_string());
96
97    let system = "You are Nyx Agent's project setup agent.\n\
98        You inspect only user-owned local repositories and prepare a launch profile that Nyx Agent can run later. \
99        Prefer deterministic, non-interactive commands. Do not use production services, remote databases, destructive remote flags, or raw secrets. \
100        If a local command prompts for confirmation, either find a documented non-interactive flag or put the required harmless local response in the launch step's `stdin` field."
101        .to_string();
102
103    let objective = format!(
104        "Project: {project_name} ({project_id})\n\
105         Target hint: {target}\n\
106         Workspace roots:\n{workspace_roots}\n\
107         Existing launch profile:\n{existing}\n\
108         Max turns: {max_turns}\n\
109         \n\
110         Work:\n\
111         1. Inspect README files, package manager files, scripts, Docker/compose files, wrangler config, env examples, migrations, seed/reset scripts, and test/dev docs.\n\
112         2. Use Bash sparingly to verify commands when safe: version checks, dependency/install hints, build scripts, migration/reset scripts, and short-lived dev-server probes. Use timeouts for commands that may keep running.\n\
113         3. Produce a Nyx Agent launch profile with `mode`, `target_urls`, and command arrays named `build_steps`, `start_steps`, `seed_steps`, `reset_steps`, `login_steps`, `stop_steps`, and `health_checks`.\n\
114         4. For commands that need a local confirmation prompt, set `stdin` on that launch step, for example `\"y\\n\"`. Prefer official non-interactive flags when the tool supports them.\n\
115         5. Emit exactly one `record_project_setup` JSON line. Include a concise `summary`, `checks`, and `warnings`. Put the JSON on its own line in the final answer; do not only describe the profile in prose.\n\
116         \n\
117         Example tool line:\n\
118         {{\"tool\":\"record_project_setup\",\"input\":{{\"summary\":\"Detected a Vite app with npm scripts and verified the dev server on localhost:5173.\",\"checks\":[\"package.json dev script found\",\"health check target selected\"],\"warnings\":[],\"profile\":{{\"name\":\"AI local dev\",\"mode\":\"custom-commands\",\"target_urls\":[\"http://127.0.0.1:5173\"],\"build_steps\":[{{\"command\":\"npm install\",\"timeout_seconds\":300}}],\"start_steps\":[{{\"command\":\"npm run dev -- --host 127.0.0.1\",\"timeout_seconds\":300}}],\"reset_steps\":[],\"seed_steps\":[],\"login_steps\":[],\"stop_steps\":[],\"health_checks\":[{{\"kind\":\"http\",\"url\":\"http://127.0.0.1:5173\",\"timeout_seconds\":60}}],\"env_refs\":[],\"working_dirs\":[]}}}}}}\n",
119        project_name = scope.project_name,
120        project_id = scope.project_id,
121        target = target,
122        workspace_roots = workspace_roots,
123        existing = existing,
124        max_turns = scope.max_turns,
125    );
126
127    AgentTask {
128        prompt_version: PROJECT_SETUP_PROMPT_VERSION.to_string(),
129        task_id: scope.task_id.clone(),
130        system,
131        objective,
132        tools: DEFAULT_PROJECT_SETUP_TOOL_NAMES.iter().map(|s| s.to_string()).collect(),
133        working_directory: scope.workspace_roots.first().cloned(),
134        max_turns: scope.max_turns,
135    }
136}
137
138fn lift_agent_result(
139    scope: &ProjectSetupScope,
140    result: AgentResult,
141) -> Result<ProjectSetupOutcome, AiError> {
142    let mut latest = None;
143    for extracted in &result.extracted {
144        if let ExtractedAgentResult::ProjectSetupProfile { profile, summary, checks, warnings } =
145            extracted
146        {
147            latest = Some((profile.clone(), summary.clone(), checks.clone(), warnings.clone()));
148        }
149    }
150    let latest = latest.or_else(|| {
151        project_setup_from_final_message(&result.final_message)
152            .map(|parsed| (parsed.profile, parsed.summary, parsed.checks, parsed.warnings))
153    });
154    let latest = latest.or_else(|| {
155        recover_project_setup_from_agent_prose(scope, &result.final_message)
156            .map(|parsed| (parsed.profile, parsed.summary, parsed.checks, parsed.warnings))
157    });
158    let Some((profile, summary, checks, warnings)) = latest else {
159        return Err(AiError::MalformedResponse(
160            format!(
161                "project setup agent did not emit record_project_setup or a parseable profile JSON, and deterministic recovery could not synthesize a launch profile; full final message:\n{}",
162                result.final_message
163            ),
164        ));
165    };
166    let profile = finalize_profile(scope, profile);
167    let metrics = AgentTraceMetrics::from_agent_result(&result);
168    Ok(ProjectSetupOutcome {
169        profile,
170        summary,
171        checks,
172        warnings,
173        final_message: result.final_message,
174        turns: result.turns,
175        spent_usd_micros: result.cost_usd_micros,
176        prompt_version: result.prompt_version,
177        metrics,
178    })
179}
180
181struct ParsedProjectSetup {
182    profile: ProjectLaunchProfileInput,
183    summary: String,
184    checks: Vec<String>,
185    warnings: Vec<String>,
186}
187
188fn project_setup_from_final_message(message: &str) -> Option<ParsedProjectSetup> {
189    json_values_from_text(message)
190        .into_iter()
191        .rev()
192        .find_map(|value| project_setup_from_value(&value))
193}
194
195fn project_setup_from_value(value: &Value) -> Option<ParsedProjectSetup> {
196    if let Some(parsed) = project_setup_from_tool_value(value) {
197        return Some(parsed);
198    }
199    if let Some(input) = value.get("input").or_else(|| value.get("arguments")) {
200        if let Some(parsed) = project_setup_from_value(&coerce_json_string(input)) {
201            return Some(parsed);
202        }
203    }
204    if let Some(record) = value.get("record_project_setup") {
205        if let Some(parsed) = project_setup_from_value(record) {
206            return Some(parsed);
207        }
208    }
209    if let Some(profile_value) = value.get("profile") {
210        let profile: ProjectLaunchProfileInput =
211            serde_json::from_value(profile_value.clone()).ok()?;
212        return Some(ParsedProjectSetup {
213            profile,
214            summary: optional_string(value, "summary")
215                .unwrap_or_else(|| "local project setup".to_string()),
216            checks: string_array(value, "checks"),
217            warnings: string_array(value, "warnings"),
218        });
219    }
220    if looks_like_launch_profile(value) {
221        let profile: ProjectLaunchProfileInput = serde_json::from_value(value.clone()).ok()?;
222        return Some(ParsedProjectSetup {
223            profile,
224            summary: "local project setup".to_string(),
225            checks: Vec::new(),
226            warnings: Vec::new(),
227        });
228    }
229    value.as_array().and_then(|items| items.iter().find_map(project_setup_from_value)).or_else(
230        || value.as_object().and_then(|obj| obj.values().find_map(project_setup_from_value)),
231    )
232}
233
234fn project_setup_from_tool_value(value: &Value) -> Option<ParsedProjectSetup> {
235    if let Some(calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
236        return calls.iter().find_map(project_setup_from_value);
237    }
238    let name = value.get("tool").or_else(|| value.get("name"))?.as_str()?;
239    let input = value
240        .get("input")
241        .or_else(|| value.get("arguments"))
242        .map(coerce_json_string)
243        .unwrap_or_else(|| serde_json::json!({}));
244    match classify_tool_use(name, &input)? {
245        ExtractedAgentResult::ProjectSetupProfile { profile, summary, checks, warnings } => {
246            Some(ParsedProjectSetup { profile, summary, checks, warnings })
247        }
248        _ => None,
249    }
250}
251
252fn looks_like_launch_profile(value: &Value) -> bool {
253    value.is_object()
254        && ["target_urls", "start_steps", "build_steps", "health_checks", "reset_steps"]
255            .iter()
256            .any(|key| value.get(key).is_some())
257}
258
259fn recover_project_setup_from_agent_prose(
260    scope: &ProjectSetupScope,
261    final_message: &str,
262) -> Option<ParsedProjectSetup> {
263    let repo_hint_text = read_repo_hint_text(&scope.workspace_roots);
264    let combined_text = if repo_hint_text.is_empty() {
265        final_message.to_string()
266    } else {
267        format!("{final_message}\n{repo_hint_text}")
268    };
269    let prose_commands = extract_commands_from_text(&combined_text);
270    let prose_urls = extract_local_urls_from_text(&combined_text);
271
272    let mut roots = scope
273        .workspace_roots
274        .iter()
275        .filter_map(|root| workspace_root_path(root))
276        .collect::<Vec<_>>();
277    roots.sort();
278    roots.dedup();
279
280    for root in &roots {
281        if let Some(parsed) =
282            recover_from_package_json(scope, root, &combined_text, &prose_commands, &prose_urls)
283        {
284            return Some(parsed);
285        }
286    }
287    for root in &roots {
288        if let Some(parsed) = recover_from_compose(scope, root, &combined_text, &prose_urls) {
289            return Some(parsed);
290        }
291    }
292    recover_from_prose_only(scope, &combined_text, &prose_commands, &prose_urls)
293}
294
295fn recover_from_package_json(
296    scope: &ProjectSetupScope,
297    root: &Path,
298    text: &str,
299    prose_commands: &[String],
300    prose_urls: &[String],
301) -> Option<ParsedProjectSetup> {
302    for package_path in find_package_jsons(root) {
303        let Ok(raw) = fs::read_to_string(&package_path) else {
304            continue;
305        };
306        let Ok(package) = serde_json::from_str::<Value>(&raw) else {
307            continue;
308        };
309        let Some(scripts) = package_scripts(&package) else {
310            continue;
311        };
312        let package_dir = package_path.parent().unwrap_or(root);
313        let runner = detect_package_runner(package_dir);
314        let start_script = choose_start_script(&scripts, prose_commands);
315        let reset_script = choose_reset_script(&scripts, prose_commands);
316        let start_body = start_script.and_then(|script| scripts.get(script)).map(String::as_str);
317        let app_kind = infer_package_app_kind(package_dir, &package, start_body, text);
318        let target_url =
319            choose_recovered_target_url(scope, text, start_body, app_kind, package_dir, prose_urls);
320
321        let mut profile = empty_profile_input();
322        profile.name = Some("AI local dev (recovered)".to_string());
323        profile.mode = Some(if start_script.is_some() {
324            "custom-commands".to_string()
325        } else {
326            "already-running".to_string()
327        });
328        if let Some(script) = start_script {
329            let command = package_script_command(&runner, script);
330            let mut step = launch_step(command, Some(path_string(package_dir)), Some(300));
331            if step_needs_local_migration_confirmation(
332                &step.command,
333                scripts.get(script).map(String::as_str),
334            ) {
335                step.stdin = Some("y\n".to_string());
336            }
337            profile.start_steps.push(step);
338        }
339        if let Some(script) = reset_script {
340            let command = package_script_command(&runner, script);
341            let mut step = launch_step(command, Some(path_string(package_dir)), Some(300));
342            if step_needs_local_migration_confirmation(
343                &step.command,
344                scripts.get(script).map(String::as_str),
345            ) {
346                step.stdin = Some("y\n".to_string());
347            }
348            profile.reset_steps.push(step);
349        }
350        if let Some(url) = target_url {
351            profile.target_urls.push(url.clone());
352            profile.health_checks.push(http_health_check(url, Some(60)));
353        }
354        if profile.start_steps.is_empty()
355            && profile.target_urls.is_empty()
356            && profile.health_checks.is_empty()
357        {
358            continue;
359        }
360
361        let mut checks = recovered_base_checks();
362        checks.push(format!("inspected {}", path_string(&package_path)));
363        if let Some(step) = profile.start_steps.first() {
364            checks.push(format!("recovered start command `{}`", step.command));
365        }
366        if let Some(step) = profile.reset_steps.first() {
367            checks.push(format!("recovered reset command `{}`", step.command));
368            if step.stdin.as_deref() == Some("y\n") {
369                checks
370                    .push("set stdin for local Wrangler migration confirmation prompt".to_string());
371            }
372        }
373        if let Some(url) = profile.target_urls.first() {
374            checks.push(format!("selected local target {url}"));
375        }
376
377        return Some(ParsedProjectSetup {
378            profile,
379            summary: format!(
380                "Recovered a local dev launch profile from agent prose and {}.",
381                app_kind.label()
382            ),
383            checks,
384            warnings: recovered_warnings(),
385        });
386    }
387    None
388}
389
390fn recover_from_compose(
391    scope: &ProjectSetupScope,
392    root: &Path,
393    text: &str,
394    prose_urls: &[String],
395) -> Option<ParsedProjectSetup> {
396    let compose_path = find_compose_file(root)?;
397    let compose_raw = fs::read_to_string(&compose_path).unwrap_or_default();
398    let target_url = scope
399        .target_base_url
400        .as_deref()
401        .and_then(normalize_local_url)
402        .or_else(|| prose_urls.first().cloned())
403        .or_else(|| extract_port_from_text(&compose_raw).map(url_for_port))?;
404
405    let mut profile = empty_profile_input();
406    profile.name = Some("AI local dev (recovered)".to_string());
407    profile.mode = Some("docker-compose".to_string());
408    profile.target_urls.push(target_url.clone());
409    profile.health_checks.push(http_health_check(target_url.clone(), Some(60)));
410
411    let mut checks = recovered_base_checks();
412    checks.push(format!("inspected {}", path_string(&compose_path)));
413    checks.push(format!("selected local target {target_url}"));
414    if text.to_ascii_lowercase().contains("docker compose") {
415        checks.push("agent prose referenced docker compose workflow".to_string());
416    }
417
418    Some(ParsedProjectSetup {
419        profile,
420        summary: "Recovered a docker compose launch profile from agent prose and repo files."
421            .to_string(),
422        checks,
423        warnings: recovered_warnings(),
424    })
425}
426
427fn recover_from_prose_only(
428    scope: &ProjectSetupScope,
429    text: &str,
430    prose_commands: &[String],
431    prose_urls: &[String],
432) -> Option<ParsedProjectSetup> {
433    let start_command = prose_commands.iter().find(|command| command_is_start(command)).cloned();
434    let target_url = scope
435        .target_base_url
436        .as_deref()
437        .and_then(normalize_local_url)
438        .or_else(|| prose_urls.first().cloned())
439        .or_else(|| inferred_default_url_from_text(text));
440    if start_command.is_none() && target_url.is_none() {
441        return None;
442    }
443
444    let mut profile = empty_profile_input();
445    profile.name = Some("AI local dev (recovered)".to_string());
446    profile.mode = Some(if start_command.is_some() {
447        "custom-commands".to_string()
448    } else {
449        "already-running".to_string()
450    });
451    if let Some(command) = start_command {
452        profile.start_steps.push(launch_step(command, None, Some(300)));
453    }
454    if let Some(url) = target_url {
455        profile.target_urls.push(url.clone());
456        profile.health_checks.push(http_health_check(url, Some(60)));
457    }
458    if profile.start_steps.is_empty()
459        && profile.target_urls.is_empty()
460        && profile.health_checks.is_empty()
461    {
462        return None;
463    }
464
465    let mut checks = recovered_base_checks();
466    if let Some(step) = profile.start_steps.first() {
467        checks.push(format!("recovered start command `{}` from agent prose", step.command));
468    }
469    if let Some(url) = profile.target_urls.first() {
470        checks.push(format!("selected local target {url}"));
471    }
472
473    Some(ParsedProjectSetup {
474        profile,
475        summary: "Recovered a local dev launch profile from agent prose.".to_string(),
476        checks,
477        warnings: recovered_warnings(),
478    })
479}
480
481#[derive(Debug, Clone, Copy)]
482enum AppKind {
483    Wrangler,
484    Vite,
485    Next,
486    ReactScripts,
487    Angular,
488    Astro,
489    Nuxt,
490    SvelteKit,
491    GenericNode,
492}
493
494impl AppKind {
495    fn label(self) -> &'static str {
496        match self {
497            AppKind::Wrangler => "Wrangler project files",
498            AppKind::Vite => "Vite project files",
499            AppKind::Next => "Next.js project files",
500            AppKind::ReactScripts => "React scripts project files",
501            AppKind::Angular => "Angular project files",
502            AppKind::Astro => "Astro project files",
503            AppKind::Nuxt => "Nuxt project files",
504            AppKind::SvelteKit => "SvelteKit project files",
505            AppKind::GenericNode => "package.json scripts",
506        }
507    }
508
509    fn default_port(self) -> Option<u16> {
510        match self {
511            AppKind::Wrangler => Some(8787),
512            AppKind::Vite | AppKind::SvelteKit => Some(5173),
513            AppKind::Next | AppKind::ReactScripts | AppKind::Nuxt | AppKind::GenericNode => {
514                Some(3000)
515            }
516            AppKind::Angular => Some(4200),
517            AppKind::Astro => Some(4321),
518        }
519    }
520}
521
522fn empty_profile_input() -> ProjectLaunchProfileInput {
523    ProjectLaunchProfileInput {
524        name: None,
525        mode: None,
526        build_steps: Vec::new(),
527        start_steps: Vec::new(),
528        seed_steps: Vec::new(),
529        reset_steps: Vec::new(),
530        login_steps: Vec::new(),
531        stop_steps: Vec::new(),
532        health_checks: Vec::new(),
533        target_urls: Vec::new(),
534        env_refs: Vec::new(),
535        working_dirs: Vec::new(),
536    }
537}
538
539fn launch_step(
540    command: impl Into<String>,
541    working_directory: Option<String>,
542    timeout_seconds: Option<u64>,
543) -> LaunchStep {
544    LaunchStep {
545        command: command.into(),
546        repo_id: None,
547        repo_name: None,
548        working_directory,
549        timeout_seconds,
550        stdin: None,
551    }
552}
553
554fn http_health_check(url: String, timeout_seconds: Option<u64>) -> LaunchHealthCheck {
555    LaunchHealthCheck {
556        kind: "http".to_string(),
557        url: Some(url),
558        host: None,
559        port: None,
560        command: None,
561        timeout_seconds,
562    }
563}
564
565fn recovered_base_checks() -> Vec<String> {
566    vec!["project setup agent ran but omitted record_project_setup".to_string()]
567}
568
569fn recovered_warnings() -> Vec<String> {
570    vec![
571        "Recovered launch profile from prose and deterministic repo inspection because the agent omitted record_project_setup; review before relying on it for unattended scans."
572            .to_string(),
573    ]
574}
575
576fn workspace_root_path(raw: &str) -> Option<PathBuf> {
577    let path = PathBuf::from(raw);
578    path.is_dir().then_some(path)
579}
580
581fn path_string(path: &Path) -> String {
582    path.to_string_lossy().to_string()
583}
584
585fn read_repo_hint_text(workspace_roots: &[String]) -> String {
586    let mut out = String::new();
587    for root in workspace_roots.iter().filter_map(|root| workspace_root_path(root)) {
588        for path in repo_hint_paths(&root) {
589            let Ok(raw) = fs::read_to_string(&path) else {
590                continue;
591            };
592            out.push_str("\n--- ");
593            out.push_str(&path_string(&path));
594            out.push_str(" ---\n");
595            out.push_str(raw.chars().take(64_000).collect::<String>().as_str());
596        }
597    }
598    out
599}
600
601fn repo_hint_paths(root: &Path) -> Vec<PathBuf> {
602    let mut paths = Vec::new();
603    for name in ["README.md", "README", "package.json", "wrangler.toml", "wrangler.json"] {
604        let path = root.join(name);
605        if path.is_file() {
606            paths.push(path);
607        }
608    }
609    let workflows = root.join(".github").join("workflows");
610    if let Ok(entries) = fs::read_dir(workflows) {
611        for entry in entries.flatten() {
612            let path = entry.path();
613            if matches!(path.extension().and_then(|ext| ext.to_str()), Some("yml" | "yaml")) {
614                paths.push(path);
615            }
616        }
617    }
618    paths
619}
620
621fn find_package_jsons(root: &Path) -> Vec<PathBuf> {
622    let mut found = Vec::new();
623    find_package_jsons_inner(root, 0, &mut found);
624    found.sort();
625    found.dedup();
626    found
627}
628
629fn find_package_jsons_inner(dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
630    if depth > 3 || should_skip_dir(dir) {
631        return;
632    }
633    let package = dir.join("package.json");
634    if package.is_file() {
635        found.push(package);
636    }
637    let Ok(entries) = fs::read_dir(dir) else {
638        return;
639    };
640    for entry in entries.flatten() {
641        let path = entry.path();
642        if path.is_dir() {
643            find_package_jsons_inner(&path, depth + 1, found);
644        }
645    }
646}
647
648fn should_skip_dir(path: &Path) -> bool {
649    matches!(
650        path.file_name().and_then(|name| name.to_str()),
651        Some(
652            ".git"
653                | ".nyx"
654                | "node_modules"
655                | "target"
656                | "dist"
657                | "build"
658                | ".next"
659                | ".turbo"
660                | "coverage"
661        )
662    )
663}
664
665fn package_scripts(package: &Value) -> Option<BTreeMap<String, String>> {
666    let scripts = package.get("scripts")?.as_object()?;
667    let scripts = scripts
668        .iter()
669        .filter_map(|(name, value)| {
670            value.as_str().map(|command| (name.to_string(), command.to_string()))
671        })
672        .collect::<BTreeMap<_, _>>();
673    (!scripts.is_empty()).then_some(scripts)
674}
675
676fn detect_package_runner(package_dir: &Path) -> String {
677    if package_dir.join("pnpm-lock.yaml").is_file() {
678        "pnpm".to_string()
679    } else if package_dir.join("yarn.lock").is_file() {
680        "yarn".to_string()
681    } else if package_dir.join("bun.lockb").is_file() || package_dir.join("bun.lock").is_file() {
682        "bun".to_string()
683    } else {
684        "npm".to_string()
685    }
686}
687
688fn choose_start_script<'a>(
689    scripts: &'a BTreeMap<String, String>,
690    prose_commands: &[String],
691) -> Option<&'a str> {
692    for command in prose_commands {
693        if let Some(script) = package_script_from_command(command) {
694            if scripts.contains_key(script) && script_name_is_start(script) {
695                return scripts.get_key_value(script).map(|(script, _)| script.as_str());
696            }
697        }
698    }
699    ["dev", "start", "serve", "preview", "wrangler:dev", "dev:wrangler", "local"]
700        .into_iter()
701        .find(|script| scripts.contains_key(*script) && script_name_is_start(script))
702}
703
704fn choose_reset_script<'a>(
705    scripts: &'a BTreeMap<String, String>,
706    prose_commands: &[String],
707) -> Option<&'a str> {
708    for command in prose_commands {
709        if let Some(script) = package_script_from_command(command) {
710            if scripts.contains_key(script) && script_name_is_reset(script) {
711                return scripts.get_key_value(script).map(|(script, _)| script.as_str());
712            }
713        }
714    }
715    ["dev:reset", "reset:dev", "db:reset", "reset:db", "migrate:reset", "migrations:reset", "reset"]
716        .into_iter()
717        .find(|script| scripts.contains_key(*script))
718        .or_else(|| scripts.keys().find(|script| script_name_is_reset(script)).map(String::as_str))
719}
720
721fn script_name_is_start(script: &str) -> bool {
722    let lower = script.to_ascii_lowercase();
723    !(lower.contains("reset")
724        || lower.contains("seed")
725        || lower.contains("test")
726        || lower.contains("build")
727        || lower.contains("lint")
728        || lower.contains("migrate"))
729        && matches!(
730            lower.as_str(),
731            "dev" | "start" | "serve" | "preview" | "wrangler:dev" | "dev:wrangler" | "local"
732        )
733}
734
735fn script_name_is_reset(script: &str) -> bool {
736    script.to_ascii_lowercase().contains("reset")
737}
738
739fn package_script_command(runner: &str, script: &str) -> String {
740    match (runner, script) {
741        ("npm", "start") => "npm start".to_string(),
742        ("npm", other) => format!("npm run {other}"),
743        ("pnpm", "start") => "pnpm start".to_string(),
744        ("pnpm", other) => format!("pnpm run {other}"),
745        ("yarn", "start") => "yarn start".to_string(),
746        ("yarn", other) => format!("yarn {other}"),
747        ("bun", "start") => "bun start".to_string(),
748        ("bun", other) => format!("bun run {other}"),
749        (_, other) => format!("npm run {other}"),
750    }
751}
752
753fn package_script_from_command(command: &str) -> Option<&str> {
754    let parts = command.split_whitespace().collect::<Vec<_>>();
755    match parts.as_slice() {
756        ["npm", "run", script, ..] => Some(strip_command_suffix(script)),
757        ["npm", "start", ..] => Some("start"),
758        ["pnpm", "run", script, ..] => Some(strip_command_suffix(script)),
759        ["pnpm", script, ..] if !package_runner_builtin(script) => {
760            Some(strip_command_suffix(script))
761        }
762        ["yarn", "run", script, ..] => Some(strip_command_suffix(script)),
763        ["yarn", script, ..] if !package_runner_builtin(script) => {
764            Some(strip_command_suffix(script))
765        }
766        ["bun", "run", script, ..] => Some(strip_command_suffix(script)),
767        ["bun", script, ..] if !package_runner_builtin(script) => {
768            Some(strip_command_suffix(script))
769        }
770        _ => None,
771    }
772}
773
774fn strip_command_suffix(raw: &str) -> &str {
775    raw.trim_matches(|ch| matches!(ch, '`' | '\'' | '"' | ',' | ';' | '.'))
776}
777
778fn package_runner_builtin(raw: &str) -> bool {
779    matches!(raw, "install" | "add" | "remove" | "exec" | "dlx" | "x" | "test" | "build" | "lint")
780}
781
782fn command_is_start(command: &str) -> bool {
783    if let Some(script) = package_script_from_command(command) {
784        return script_name_is_start(script);
785    }
786    let lower = command.to_ascii_lowercase();
787    lower == "wrangler dev"
788        || lower.starts_with("wrangler dev ")
789        || lower == "cargo run"
790        || lower.starts_with("docker compose up")
791        || lower.starts_with("docker-compose up")
792}
793
794fn step_needs_local_migration_confirmation(command: &str, script_body: Option<&str>) -> bool {
795    let mut lower = command.to_ascii_lowercase();
796    if let Some(body) = script_body {
797        lower.push('\n');
798        lower.push_str(&body.to_ascii_lowercase());
799    }
800    lower.contains("wrangler")
801        && lower.contains("migrations apply")
802        && (lower.contains("--local") || lower.contains(" d1 "))
803}
804
805fn infer_package_app_kind(
806    package_dir: &Path,
807    package: &Value,
808    start_body: Option<&str>,
809    text: &str,
810) -> AppKind {
811    let start_lower = start_body.unwrap_or_default().to_ascii_lowercase();
812    let mut combined = start_lower.clone();
813    for key in ["dependencies", "devDependencies"] {
814        if let Some(deps) = package.get(key).and_then(|value| value.as_object()) {
815            for dep in deps.keys() {
816                combined.push('\n');
817                combined.push_str(&dep.to_ascii_lowercase());
818            }
819        }
820    }
821    if has_wrangler_config(package_dir)
822        || start_lower.contains("wrangler")
823        || (start_body.is_none() && text.to_ascii_lowercase().contains("wrangler"))
824    {
825        AppKind::Wrangler
826    } else if combined.contains("next dev") || combined.contains("\nnext") {
827        AppKind::Next
828    } else if combined.contains("react-scripts start") || combined.contains("\nreact-scripts") {
829        AppKind::ReactScripts
830    } else if combined.contains("ng serve") || combined.contains("@angular/") {
831        AppKind::Angular
832    } else if combined.contains("astro dev") || combined.contains("\nastro") {
833        AppKind::Astro
834    } else if combined.contains("nuxt dev") || combined.contains("\nnuxt") {
835        AppKind::Nuxt
836    } else if combined.contains("svelte-kit") || combined.contains("@sveltejs/kit") {
837        AppKind::SvelteKit
838    } else if combined.contains("vite") {
839        AppKind::Vite
840    } else {
841        AppKind::GenericNode
842    }
843}
844
845fn has_wrangler_config(package_dir: &Path) -> bool {
846    ["wrangler.toml", "wrangler.json", "wrangler.jsonc"]
847        .iter()
848        .any(|name| package_dir.join(name).is_file())
849}
850
851fn choose_recovered_target_url(
852    scope: &ProjectSetupScope,
853    text: &str,
854    start_body: Option<&str>,
855    app_kind: AppKind,
856    package_dir: &Path,
857    prose_urls: &[String],
858) -> Option<String> {
859    scope
860        .target_base_url
861        .as_deref()
862        .and_then(normalize_local_url)
863        .or_else(|| prose_urls.first().cloned())
864        .or_else(|| start_body.and_then(|body| extract_local_urls_from_text(body).first().cloned()))
865        .or_else(|| start_body.and_then(extract_port_from_text).map(url_for_port))
866        .or_else(|| wrangler_config_port(package_dir).map(url_for_port))
867        .or_else(|| extract_port_from_text(text).map(url_for_port))
868        .or_else(|| app_kind.default_port().map(url_for_port))
869}
870
871fn wrangler_config_port(package_dir: &Path) -> Option<u16> {
872    for name in ["wrangler.toml", "wrangler.json", "wrangler.jsonc"] {
873        let Ok(raw) = fs::read_to_string(package_dir.join(name)) else {
874            continue;
875        };
876        if let Some(port) = extract_port_from_text(&raw) {
877            return Some(port);
878        }
879    }
880    None
881}
882
883fn find_compose_file(root: &Path) -> Option<PathBuf> {
884    for name in ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"] {
885        let path = root.join(name);
886        if path.is_file() {
887            return Some(path);
888        }
889    }
890    None
891}
892
893fn inferred_default_url_from_text(text: &str) -> Option<String> {
894    let lower = text.to_ascii_lowercase();
895    if lower.contains("wrangler") {
896        Some(url_for_port(8787))
897    } else if lower.contains("vite") {
898        Some(url_for_port(5173))
899    } else if lower.contains("next dev") || lower.contains("react-scripts") {
900        Some(url_for_port(3000))
901    } else if lower.contains("ng serve") {
902        Some(url_for_port(4200))
903    } else {
904        extract_port_from_text(text).map(url_for_port)
905    }
906}
907
908fn extract_commands_from_text(text: &str) -> Vec<String> {
909    let mut commands = Vec::new();
910    let mut rest = text;
911    while let Some(start) = rest.find('`') {
912        rest = &rest[start + 1..];
913        let Some(end) = rest.find('`') else {
914            break;
915        };
916        let snippet = rest[..end].trim();
917        if looks_like_command(snippet) {
918            commands.push(clean_command(snippet));
919        }
920        rest = &rest[end + 1..];
921    }
922
923    let lower = text.to_ascii_lowercase();
924    let mut indexed = [
925        "npm run dev:reset",
926        "npm run dev",
927        "npm start",
928        "pnpm run dev:reset",
929        "pnpm run dev",
930        "pnpm dev",
931        "pnpm start",
932        "yarn dev:reset",
933        "yarn dev",
934        "yarn start",
935        "bun run dev:reset",
936        "bun run dev",
937        "bun dev",
938        "wrangler dev",
939        "docker compose up",
940        "docker-compose up",
941        "cargo run",
942    ]
943    .into_iter()
944    .filter_map(|command| lower.find(command).map(|idx| (idx, command.to_string())))
945    .collect::<Vec<_>>();
946    indexed.sort_by_key(|(idx, _)| *idx);
947    commands.extend(indexed.into_iter().map(|(_, command)| command));
948
949    dedupe_preserve_order(commands)
950}
951
952fn looks_like_command(snippet: &str) -> bool {
953    let lower = snippet.to_ascii_lowercase();
954    [
955        "npm ",
956        "pnpm ",
957        "yarn ",
958        "bun ",
959        "wrangler ",
960        "docker compose ",
961        "docker-compose ",
962        "cargo ",
963        "make ",
964    ]
965    .iter()
966    .any(|prefix| lower.starts_with(prefix))
967}
968
969fn clean_command(raw: &str) -> String {
970    raw.split_whitespace().collect::<Vec<_>>().join(" ")
971}
972
973fn extract_local_urls_from_text(text: &str) -> Vec<String> {
974    let mut urls = Vec::new();
975    for scheme in ["http://", "https://"] {
976        let mut search = 0;
977        while let Some(relative) = text[search..].find(scheme) {
978            let start = search + relative;
979            let tail = &text[start..];
980            let end = tail
981                .char_indices()
982                .find(|(_, ch)| {
983                    ch.is_whitespace()
984                        || matches!(ch, '`' | '\'' | '"' | '<' | '>' | ')' | ']' | '}')
985                })
986                .map(|(idx, _)| start + idx)
987                .unwrap_or(text.len());
988            if let Some(url) = normalize_local_url(&text[start..end]) {
989                urls.push(url);
990            }
991            search = end.saturating_add(1).min(text.len());
992        }
993    }
994    dedupe_preserve_order(urls)
995}
996
997fn normalize_local_url(raw: &str) -> Option<String> {
998    let trimmed = raw.trim().trim_matches(|ch| {
999        matches!(ch, '`' | '\'' | '"' | '<' | '>' | '(' | ')' | '[' | ']' | ',' | ';' | '.')
1000    });
1001    let mut url = reqwest::Url::parse(trimmed).ok()?;
1002    if !matches!(url.scheme(), "http" | "https") {
1003        return None;
1004    }
1005    let host = url.host_str()?;
1006    if host.eq_ignore_ascii_case("localhost") || host == "0.0.0.0" || host == "::1" {
1007        url.set_host(Some("127.0.0.1")).ok()?;
1008    } else if !(host == "127.0.0.1" || host.starts_with("127.")) {
1009        return None;
1010    }
1011    let mut out = url.to_string();
1012    if url.path() == "/" && url.query().is_none() && url.fragment().is_none() {
1013        out = out.trim_end_matches('/').to_string();
1014    }
1015    Some(out)
1016}
1017
1018fn extract_port_from_text(text: &str) -> Option<u16> {
1019    let lower = text.to_ascii_lowercase();
1020    for pattern in ["localhost:", "127.0.0.1:", "0.0.0.0:", "--port=", "--port ", "-p ", "port "] {
1021        if let Some(port) = parse_port_after(&lower, pattern) {
1022            return Some(port);
1023        }
1024    }
1025    None
1026}
1027
1028fn parse_port_after(text: &str, pattern: &str) -> Option<u16> {
1029    let mut search = 0;
1030    while let Some(relative) = text[search..].find(pattern) {
1031        let mut idx = search + relative + pattern.len();
1032        while idx < text.len() && matches!(text.as_bytes()[idx], b' ' | b'=' | b':') {
1033            idx += 1;
1034        }
1035        let digit_start = idx;
1036        while idx < text.len() && text.as_bytes()[idx].is_ascii_digit() {
1037            idx += 1;
1038        }
1039        if idx > digit_start {
1040            if let Ok(port) = text[digit_start..idx].parse::<u16>() {
1041                if port > 0 {
1042                    return Some(port);
1043                }
1044            }
1045        }
1046        search = idx.saturating_add(1).min(text.len());
1047    }
1048    None
1049}
1050
1051fn url_for_port(port: u16) -> String {
1052    format!("http://127.0.0.1:{port}")
1053}
1054
1055fn dedupe_preserve_order(items: Vec<String>) -> Vec<String> {
1056    let mut seen = BTreeSet::new();
1057    let mut out = Vec::new();
1058    for item in items {
1059        if seen.insert(item.clone()) {
1060            out.push(item);
1061        }
1062    }
1063    out
1064}
1065
1066fn coerce_json_string(value: &Value) -> Value {
1067    value
1068        .as_str()
1069        .and_then(|s| serde_json::from_str::<Value>(s).ok())
1070        .unwrap_or_else(|| value.clone())
1071}
1072
1073fn finalize_profile(
1074    scope: &ProjectSetupScope,
1075    mut profile: ProjectLaunchProfileInput,
1076) -> ProjectLaunchProfileInput {
1077    if profile.name.as_ref().is_none_or(|name| name.trim().is_empty()) {
1078        profile.name = Some("AI local dev".to_string());
1079    }
1080    if profile.mode.as_ref().is_none_or(|mode| mode.trim().is_empty()) {
1081        profile.mode = Some(if profile.start_steps.is_empty() {
1082            "already-running".to_string()
1083        } else {
1084            "custom-commands".to_string()
1085        });
1086    }
1087    profile.build_steps = clean_steps(profile.build_steps);
1088    profile.start_steps = clean_steps(profile.start_steps);
1089    profile.seed_steps = clean_steps(profile.seed_steps);
1090    profile.reset_steps = clean_steps(profile.reset_steps);
1091    profile.login_steps = clean_steps(profile.login_steps);
1092    profile.stop_steps = clean_steps(profile.stop_steps);
1093    profile.health_checks = clean_health_checks(profile.health_checks);
1094    profile.target_urls.retain(|url| !url.trim().is_empty());
1095    if profile.target_urls.is_empty() {
1096        if let Some(target) = scope.target_base_url.as_ref().filter(|url| !url.trim().is_empty()) {
1097            profile.target_urls.push(target.clone());
1098        }
1099    }
1100    profile
1101}
1102
1103fn clean_steps(steps: Vec<LaunchStep>) -> Vec<LaunchStep> {
1104    steps
1105        .into_iter()
1106        .filter_map(|mut step| {
1107            step.command = step.command.trim().to_string();
1108            if step.command.is_empty() {
1109                return None;
1110            }
1111            step.repo_name = trim_option(step.repo_name);
1112            step.repo_id = trim_option(step.repo_id);
1113            step.working_directory = trim_option(step.working_directory);
1114            step.stdin = clean_stdin_option(step.stdin);
1115            Some(step)
1116        })
1117        .collect()
1118}
1119
1120fn clean_health_checks(checks: Vec<LaunchHealthCheck>) -> Vec<LaunchHealthCheck> {
1121    checks
1122        .into_iter()
1123        .filter_map(|mut check| {
1124            check.kind = check.kind.trim().to_string();
1125            check.url = trim_option(check.url);
1126            check.host = trim_option(check.host);
1127            check.command =
1128                check.command.and_then(|step| clean_steps(vec![step]).into_iter().next());
1129            if check.kind.is_empty() {
1130                check.kind = if check.command.is_some() { "command" } else { "http" }.to_string();
1131            }
1132            if check.url.is_none() && check.command.is_none() && check.host.is_none() {
1133                return None;
1134            }
1135            Some(check)
1136        })
1137        .collect()
1138}
1139
1140fn trim_option(value: Option<String>) -> Option<String> {
1141    let trimmed = value?.trim().to_string();
1142    if trimmed.is_empty() {
1143        None
1144    } else {
1145        Some(trimmed)
1146    }
1147}
1148
1149fn clean_stdin_option(value: Option<String>) -> Option<String> {
1150    let value = value?;
1151    if value.trim().is_empty() {
1152        None
1153    } else {
1154        Some(value)
1155    }
1156}
1157
1158fn render_list(items: &[String], empty: &str) -> String {
1159    if items.is_empty() {
1160        return empty.to_string();
1161    }
1162    items.iter().map(|item| format!("- {item}")).collect::<Vec<_>>().join("\n")
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168    use async_trait::async_trait;
1169    use nyx_agent_types::agent::{CostEstimate, Prompt, Response, TokenUsage};
1170    use tokio::sync::broadcast;
1171
1172    struct ScriptedRuntime {
1173        result: AgentResult,
1174    }
1175
1176    #[async_trait]
1177    impl AiRuntime for ScriptedRuntime {
1178        fn name(&self) -> &'static str {
1179            "scripted"
1180        }
1181
1182        fn default_model(&self) -> &str {
1183            "scripted"
1184        }
1185
1186        fn supports_agent_loop(&self) -> bool {
1187            true
1188        }
1189
1190        fn supports_prompt_cache(&self) -> bool {
1191            false
1192        }
1193
1194        fn supports_deterministic_sampling(&self) -> bool {
1195            true
1196        }
1197
1198        async fn one_shot(
1199            &self,
1200            _prompt: Prompt,
1201            _budget: Budget,
1202            _sink: EventSink,
1203        ) -> Result<Response, AiError> {
1204            Err(AiError::UnsupportedMode("one_shot"))
1205        }
1206
1207        async fn agent_loop(
1208            &self,
1209            task: AgentTask,
1210            _budget: Budget,
1211            _sink: EventSink,
1212        ) -> Result<AgentResult, AiError> {
1213            assert!(task.objective.contains("record_project_setup"));
1214            Ok(self.result.clone())
1215        }
1216
1217        fn cost_estimate(&self, _prompt: &Prompt) -> Option<CostEstimate> {
1218            None
1219        }
1220    }
1221
1222    fn fake_result_with_message(
1223        final_message: impl Into<String>,
1224        extracted: Vec<ExtractedAgentResult>,
1225    ) -> AgentResult {
1226        AgentResult {
1227            prompt_version: PROJECT_SETUP_PROMPT_VERSION.to_string(),
1228            task_id: "project-setup-p1".to_string(),
1229            model: "scripted".to_string(),
1230            final_message: final_message.into(),
1231            turns: 4,
1232            usage: TokenUsage { input_tokens: 100, output_tokens: 50 },
1233            cache: None,
1234            cost_usd_micros: 123,
1235            extracted,
1236        }
1237    }
1238
1239    #[test]
1240    fn parses_pretty_fenced_project_setup_tool_marker() {
1241        let text = r#"
1242```json
1243{
1244  "tool": "record_project_setup",
1245  "input": {
1246    "summary": "Detected wrangler workflow.",
1247    "checks": ["package.json inspected"],
1248    "warnings": [],
1249    "profile": {
1250      "target_urls": ["http://127.0.0.1:8787"],
1251      "start_steps": [{"command": "npm run dev"}],
1252      "reset_steps": [{"command": "npm run dev:reset", "stdin": "y\n"}]
1253    }
1254  }
1255}
1256```
1257"#;
1258        let parsed = project_setup_from_final_message(text).expect("parsed");
1259        assert_eq!(parsed.summary, "Detected wrangler workflow.");
1260        assert_eq!(parsed.profile.target_urls, vec!["http://127.0.0.1:8787"]);
1261        assert_eq!(parsed.profile.reset_steps[0].stdin.as_deref(), Some("y\n"));
1262    }
1263
1264    #[test]
1265    fn parses_direct_project_setup_profile_object() {
1266        let text = r#"
1267{
1268  "summary": "Direct profile",
1269  "profile": {
1270    "target_urls": ["http://localhost:3000"],
1271    "health_checks": [{"kind": "http", "url": "http://localhost:3000"}]
1272  }
1273}
1274"#;
1275        let parsed = project_setup_from_final_message(text).expect("parsed");
1276        assert_eq!(parsed.summary, "Direct profile");
1277        assert_eq!(parsed.profile.health_checks[0].kind, "http");
1278    }
1279
1280    #[tokio::test]
1281    async fn recovers_profile_from_agent_prose_and_repo_files() {
1282        let repo = tempfile::tempdir().expect("tempdir");
1283        fs::write(
1284            repo.path().join("package.json"),
1285            r#"{"scripts":{"dev":"wrangler dev","dev:reset":"wrangler d1 migrations apply DB --local"}}"#,
1286        )
1287        .expect("write package");
1288        let final_message = "The project\u{2019}s own workflow confirms `npm run dev` is the local entry point: it migrates, seeds, then starts Wrangler...";
1289        let runtime =
1290            ScriptedRuntime { result: fake_result_with_message(final_message, Vec::new()) };
1291        let mut scope = ProjectSetupScope::new("p1", "PrismTrips");
1292        scope.workspace_roots = vec![path_string(repo.path())];
1293        let (sink, _rx) = broadcast::channel(1);
1294
1295        let outcome = run(&runtime, &scope, sink).await.expect("recovered");
1296
1297        assert_eq!(outcome.profile.start_steps[0].command, "npm run dev");
1298        assert_eq!(outcome.profile.reset_steps[0].command, "npm run dev:reset");
1299        assert_eq!(outcome.profile.reset_steps[0].stdin.as_deref(), Some("y\n"));
1300        assert_eq!(outcome.profile.target_urls, vec!["http://127.0.0.1:8787"]);
1301        assert_eq!(outcome.profile.health_checks[0].url.as_deref(), Some("http://127.0.0.1:8787"));
1302        assert!(outcome
1303            .warnings
1304            .iter()
1305            .any(|warning| warning.contains("Recovered launch profile from prose")));
1306    }
1307
1308    #[tokio::test]
1309    async fn recovered_wrangler_dev_reset_sets_confirmation_stdin() {
1310        let repo = tempfile::tempdir().expect("tempdir");
1311        fs::write(
1312            repo.path().join("package.json"),
1313            r#"{"scripts":{"dev":"vite --host 127.0.0.1","dev:reset":"wrangler d1 migrations apply DB --local"}}"#,
1314        )
1315        .expect("write package");
1316        let runtime = ScriptedRuntime {
1317            result: fake_result_with_message(
1318                "Use `npm run dev` for local development and `npm run dev:reset` to reset the local database.",
1319                Vec::new(),
1320            ),
1321        };
1322        let mut scope = ProjectSetupScope::new("p1", "wrangler-db-app");
1323        scope.workspace_roots = vec![path_string(repo.path())];
1324        let (sink, _rx) = broadcast::channel(1);
1325
1326        let outcome = run(&runtime, &scope, sink).await.expect("recovered");
1327
1328        assert_eq!(outcome.profile.reset_steps[0].command, "npm run dev:reset");
1329        assert_eq!(outcome.profile.reset_steps[0].stdin.as_deref(), Some("y\n"));
1330    }
1331
1332    #[test]
1333    fn malformed_error_exposes_full_final_message() {
1334        let mut long_message =
1335            "The agent answered in prose without any usable local launch command. ".repeat(10);
1336        long_message.push_str("UNIQUE_FULL_FINAL_MESSAGE_TAIL");
1337        let err = lift_agent_result(
1338            &ProjectSetupScope::new("p1", "nope"),
1339            fake_result_with_message(long_message, Vec::new()),
1340        )
1341        .expect_err("malformed");
1342        let detail = err.to_string();
1343        assert!(detail.contains("full final message"));
1344        assert!(detail.contains("UNIQUE_FULL_FINAL_MESSAGE_TAIL"));
1345    }
1346}