1use 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}