1use std::path::PathBuf;
2use std::sync::Arc;
3
4use tracing::{info, warn};
5
6use super::workflows::reject;
7use super::{Application, ApplicationError, CommandDisposition, RequestId, rejection};
8use crate::event::{CommandResult, EventKind, StateEvent};
9use crate::harness::launch::validate_options;
10use crate::harness::{
11 HarnessDefinition, HarnessError, HarnessId, LocatedHarness, LoginPath, locate_in,
12};
13use crate::terminal::{TerminalSize, TerminalStatus};
14use crate::workflow::{WorkflowId, WorkflowKind, WorkflowStatus, timestamp};
15
16fn locate(
19 definition: &HarnessDefinition,
20 test_path: Option<&std::ffi::OsStr>,
21 login_path: &LoginPath,
22) -> Result<LocatedHarness, HarnessError> {
23 if let Some(path) = test_path {
24 return locate_in(definition, Some(path), None);
25 }
26 if let Some(path) = std::env::var_os("TWINE_HARNESS_PATH") {
28 return locate_in(definition, Some(&path), None);
29 }
30 locate_in(
31 definition,
32 login_path.get().as_deref(),
33 std::env::var_os("PATH").as_deref(),
34 )
35}
36
37impl Application {
38 pub(super) fn locate_harness(
41 &self,
42 definition: &HarnessDefinition,
43 ) -> Result<LocatedHarness, HarnessError> {
44 #[cfg(test)]
45 let test_path = self.harness_path.as_deref();
46 #[cfg(not(test))]
47 let test_path = None;
48 locate(definition, test_path, &self.login_path)
49 }
50
51 pub fn request_harness_models(
54 &self,
55 harness: HarnessId,
56 folder: Option<PathBuf>,
57 ) -> crate::ModelListRequest {
58 #[cfg(test)]
59 let test_path = self.harness_path.clone();
60 #[cfg(not(test))]
61 let test_path: Option<std::ffi::OsString> = None;
62 let login_path = Arc::clone(&self.login_path);
63 self.model_lists.request(harness, folder, move || {
64 locate(harness.definition(), test_path.as_deref(), &login_path)
65 })
66 }
67
68 pub(super) fn draft_folder(
70 &self,
71 workflow_id: WorkflowId,
72 ) -> Result<Result<PathBuf, CommandDisposition>, ApplicationError> {
73 let inner = self.lock_inner()?;
74 let Some(workflow) = inner
75 .workflows
76 .workflows
77 .iter()
78 .find(|workflow| workflow.workflow_id == workflow_id)
79 else {
80 return Ok(Err(reject(
81 "workflowNotFound",
82 "The workflow is no longer open.",
83 )));
84 };
85 if workflow.kind != WorkflowKind::Draft {
86 return Ok(Err(reject(
87 "workflowNotDraft",
88 "The workflow is already configured.",
89 )));
90 }
91 let session = inner
92 .workflows
93 .sessions
94 .iter()
95 .find(|session| session.session_id == workflow.session_id);
96 Ok(session
97 .map(|session| session.folder.clone())
98 .ok_or_else(|| reject("workflowNotFound", "The workflow is no longer open.")))
99 }
100
101 #[expect(
104 clippy::too_many_lines,
105 reason = "agent replacement keeps process, history, and trace cleanup together"
106 )]
107 pub(super) fn start_agent(
108 &self,
109 request_id: RequestId,
110 workflow_id: WorkflowId,
111 harness: HarnessId,
112 (model, effort, yolo): (Option<&str>, Option<&str>, bool),
113 prompt: &str,
114 size: TerminalSize,
115 ) -> Result<CommandDisposition, ApplicationError> {
116 let prompt = prompt.trim();
117 let Some(options) = validate_options(harness, model, effort, yolo) else {
118 return Ok(reject(
119 "invalidModel",
120 "Choose a model and effort from the lists, or type one model name.",
121 ));
122 };
123 let folder = match self.draft_folder(workflow_id)? {
124 Ok(folder) => folder,
125 Err(rejected) => return Ok(rejected),
126 };
127
128 let definition = harness.definition();
129 let located = match self.locate_harness(definition) {
130 Ok(located) => located,
131 Err(error) => return Ok(rejection("harnessNotFound", &error)),
132 };
133
134 let reserved = match self.terminals.reserve_terminal() {
137 Ok(id) => id,
138 Err(error) => return Ok(rejection("agentStartFailed", &error)),
139 };
140 let (arguments, hooks) = self.harness_arguments(harness, reserved, options, prompt);
141 let mut inner = self.lock_inner()?;
142 let placeholder_size = inner
144 .workflows
145 .workflows
146 .iter()
147 .find(|workflow| workflow.workflow_id == workflow_id)
148 .and_then(|workflow| self.terminals.size(workflow.terminal_id))
149 .unwrap_or(size);
150 let terminal_id = match self.terminals.start_program(
151 reserved,
152 &folder,
153 &located.program,
154 &arguments,
155 &located.path,
156 &crate::harness::launch::observed_environment(harness, hooks.as_ref()),
157 placeholder_size,
158 Arc::new(self.exit_callback()),
159 ) {
160 Ok(terminal_id) => terminal_id,
161 Err(error) => return Ok(rejection("agentStartFailed", &error)),
162 };
163 let Some(index) = inner
164 .workflows
165 .workflows
166 .iter()
167 .position(|workflow| workflow.workflow_id == workflow_id)
168 else {
169 drop(inner);
170 let _ = self.terminals.close(terminal_id);
171 return Ok(reject(
172 "workflowNotFound",
173 "The workflow is no longer open.",
174 ));
175 };
176 let mut workflow = inner.workflows.workflows[index].clone();
177 let placeholder = workflow.terminal_id;
178 let name = definition.name;
179 workflow.kind = WorkflowKind::SingleAgent;
180 workflow.restored = false;
181 workflow.terminal_history.clear();
182 workflow.harness = Some(harness);
183 name.clone_into(&mut workflow.name);
184 workflow.terminal_id = terminal_id;
185 workflow.status = WorkflowStatus::Running;
186 workflow.started_at = timestamp();
187 workflow.ended_at = None;
188 if let Err(error) = inner.start_agent_trace(
189 &workflow,
190 harness,
191 placeholder,
192 self.terminals.observe(placeholder).ok(),
193 None,
194 ) {
195 drop(inner);
196 let _ = self.terminals.close(terminal_id);
197 return Err(error);
198 }
199 inner.terminals.remove(&placeholder);
200 inner.terminals.insert(terminal_id, TerminalStatus::Running);
201 inner.workflows.workflows[index] = workflow.clone();
202 inner
203 .events
204 .append(EventKind::State(StateEvent::WorkflowChanged(workflow)))?;
205 inner.events.append(EventKind::CommandCompleted {
206 request_id,
207 result: CommandResult::AgentStarted { workflow_id },
208 })?;
209 drop(inner);
210 if let Some(hooks) = hooks
211 && let Err(error) = self.register_harness_steps(terminal_id, workflow_id, true, hooks)
212 {
213 warn!(%error, "couldn't register harness step hooks");
214 }
215 info!(
216 workflow_id = workflow_id.0,
217 terminal_id = terminal_id.value(),
218 harness = name,
219 "agent started"
220 );
221 if placeholder.value() != 0 {
222 let _ = self.terminals.close_in_background(placeholder);
225 }
226 Ok(CommandDisposition::Accepted)
227 }
228
229 pub(super) fn cancel_agent(
231 &self,
232 request_id: RequestId,
233 workflow_id: WorkflowId,
234 ) -> Result<CommandDisposition, ApplicationError> {
235 let terminal_id = {
236 let mut inner = self.lock_inner()?;
237 let Some(index) = inner
238 .workflows
239 .workflows
240 .iter()
241 .position(|workflow| workflow.workflow_id == workflow_id)
242 else {
243 return Ok(reject(
244 "workflowNotFound",
245 "The workflow is no longer open.",
246 ));
247 };
248 let mut workflow = inner.workflows.workflows[index].clone();
249 if workflow.kind != WorkflowKind::SingleAgent
250 || workflow.status != WorkflowStatus::Running
251 {
252 return Ok(reject("agentNotRunning", "The agent isn't running."));
253 }
254 if let Ok(observation) = self.terminals.observe(workflow.terminal_id)
255 && let Err(error) = inner.stop_trace(
256 workflow.terminal_id,
257 observation,
258 "Process stopped when its agent was cancelled.",
259 )
260 {
261 warn!(%error, "failed to record the cancelled agent's trace");
262 }
263 workflow.status = WorkflowStatus::Cancelled;
264 workflow.ended_at = Some(timestamp().max(workflow.started_at));
265 if let Err(error) = inner
266 .folders
267 .store()
268 .update_agent_status(workflow_id, WorkflowStatus::Cancelled)
269 {
270 warn!(%error, "failed to record the cancelled agent");
271 }
272 let terminal_id = workflow.terminal_id;
273 inner.workflows.workflows[index] = workflow.clone();
274 inner
275 .events
276 .append(EventKind::State(StateEvent::WorkflowChanged(workflow)))?;
277 terminal_id
278 };
279 if let Err(error) = self.terminals.terminate(terminal_id) {
282 warn!(workflow_id = workflow_id.0, %error, "failed to stop cancelled agent");
283 }
284 info!(workflow_id = workflow_id.0, "agent cancelled");
285 self.lock_inner()?
286 .events
287 .append(EventKind::CommandCompleted {
288 request_id,
289 result: CommandResult::AgentCancelled { workflow_id },
290 })?;
291 Ok(CommandDisposition::Accepted)
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use std::ffi::OsString;
298 use std::os::unix::fs::PermissionsExt;
299 use std::path::Path;
300 use std::thread;
301 use std::time::{Duration, Instant};
302
303 use super::*;
304 use crate::Command;
305
306 const SIZE: TerminalSize = TerminalSize {
307 rows: 24,
308 columns: 80,
309 pixel_width: 800,
310 pixel_height: 480,
311 };
312
313 fn application(folder: &Path, bin: &Path, script: Option<&str>) -> Application {
315 if let Some(script) = script {
316 let binary = bin.join("pi");
317 std::fs::write(&binary, format!("#!/bin/sh\n{script}\n")).unwrap();
318 std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
319 }
320 let mut application = Application::with_event_capacity(4096).unwrap();
321 application.harness_path = Some(OsString::from(format!("{}:/bin:/usr/bin", bin.display())));
322 application
323 .handle_command(
324 RequestId(1),
325 Command::OpenFolder {
326 path: folder.to_owned(),
327 },
328 )
329 .unwrap();
330 application
331 }
332
333 #[test]
334 fn runtime_shutdown_stops_live_model_requests_without_stopping_another_runtime() {
335 use crate::process::tests::FixtureProcess;
336
337 let first = tempfile::tempdir().unwrap();
338 let second = tempfile::tempdir().unwrap();
339 let make_runtime = |directory: &Path| {
340 application(
341 directory,
342 directory,
343 Some(&format!(
344 "echo $$ > '{}'; exec sleep 60",
345 directory.join("helper.pid").display()
346 )),
347 )
348 };
349 let first_app = make_runtime(first.path());
350 let second_app = make_runtime(second.path());
351 let first_request = first_app.request_harness_models(HarnessId::Pi, None);
352 let second_request = second_app.request_harness_models(HarnessId::Pi, None);
353 let first_helper = FixtureProcess::read(&first.path().join("helper.pid"));
354 let second_helper = FixtureProcess::read(&second.path().join("helper.pid"));
355
356 drop(first_app);
357 assert!(!first_helper.is_running());
358 assert!(matches!(
359 first_request.poll(),
360 Some(Err(crate::ModelListError::Cancelled))
361 ));
362 assert!(
363 second_helper.is_running(),
364 "other windows keep their own helpers"
365 );
366 assert!(second_request.poll().is_none());
367
368 drop(second_app);
369 assert!(!second_helper.is_running());
370 assert!(matches!(
371 second_request.poll(),
372 Some(Err(crate::ModelListError::Cancelled))
373 ));
374 }
375
376 #[test]
377 fn runtime_shutdown_cancels_discovery_waiting_on_the_login_shell() {
378 use crate::process::tests::FixtureProcess;
379
380 let directory = tempfile::tempdir().unwrap();
381 let pid_path = directory.path().join("shell.pid");
382 let shell_path = directory.path().join("shell");
383 std::fs::write(
384 &shell_path,
385 format!(
386 "#!/bin/sh\necho $$ > '{}'; exec sleep 60\n",
387 pid_path.display()
388 ),
389 )
390 .unwrap();
391 std::fs::set_permissions(&shell_path, std::fs::Permissions::from_mode(0o755)).unwrap();
392 let mut app = Application::with_event_capacity(128).unwrap();
393 app.login_path = Arc::new(LoginPath::spawn(shell_path));
394 let request = app.request_harness_models(HarnessId::Pi, None);
395 let shell = FixtureProcess::read(&pid_path);
396 let started = Instant::now();
397 drop(app);
398 assert!(started.elapsed() < Duration::from_secs(2));
399 assert!(!shell.is_running());
400 assert!(matches!(
401 request.poll(),
402 Some(Err(crate::ModelListError::Cancelled))
403 ));
404 }
405
406 fn draft(application: &Application, folder: &Path) -> crate::Workflow {
407 application
408 .handle_command(
409 RequestId(2),
410 Command::CreateWorkflow {
411 folder: folder.to_owned(),
412 session_id: None,
413 kind: WorkflowKind::Draft,
414 roles: Vec::new(),
415 size: SIZE,
416 },
417 )
418 .unwrap();
419 application.snapshot().unwrap().workflows.workflows[0].clone()
420 }
421
422 fn draft_at(application: &Application, folder: &Path) -> crate::Workflow {
423 application
424 .handle_command(
425 RequestId(2),
426 Command::CreateWorkflow {
427 folder: folder.to_owned(),
428 session_id: None,
429 kind: WorkflowKind::Draft,
430 roles: Vec::new(),
431 size: SIZE,
432 },
433 )
434 .unwrap();
435 application
436 .snapshot()
437 .unwrap()
438 .workflows
439 .workflows
440 .last()
441 .unwrap()
442 .clone()
443 }
444
445 fn status_of(application: &Application, id: WorkflowId) -> WorkflowStatus {
446 application
447 .snapshot()
448 .unwrap()
449 .workflows
450 .workflows
451 .iter()
452 .find(|workflow| workflow.workflow_id == id)
453 .unwrap()
454 .status
455 }
456
457 fn start(
458 application: &Application,
459 workflow_id: WorkflowId,
460 prompt: &str,
461 ) -> CommandDisposition {
462 application
463 .handle_command(
464 RequestId(3),
465 Command::StartAgent {
466 model: None,
467 effort: None,
468 yolo: false,
469 workflow_id,
470 harness: HarnessId::Pi,
471 prompt: prompt.to_owned(),
472 size: SIZE,
473 },
474 )
475 .unwrap()
476 .disposition
477 }
478
479 fn workflow(application: &Application) -> crate::Workflow {
480 application.snapshot().unwrap().workflows.workflows[0].clone()
481 }
482
483 fn wait_until(mut condition: impl FnMut() -> bool) {
484 let deadline = Instant::now() + Duration::from_secs(10);
485 while !condition() {
486 assert!(Instant::now() < deadline, "agent condition timed out");
487 thread::sleep(Duration::from_millis(10));
488 }
489 }
490
491 fn output_contains(application: &Application, output: &mut Vec<u8>, needle: &str) -> bool {
492 while let Some(chunk) = application.next_terminal_chunk().unwrap() {
493 output.extend_from_slice(&chunk.bytes);
494 }
495 String::from_utf8_lossy(output).contains(needle)
496 }
497
498 #[test]
499 fn the_agent_gets_the_prompt_and_an_interactive_terminal() {
500 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
501 let application = application(
502 folder.path(),
503 bin.path(),
504 Some(
505 "while [ \"$#\" -gt 0 ] && [ \"$1\" != -- ]; do shift; done; printf 'ARGS:%s|%s\\n' \"$1\" \"$2\"; read line; echo \"GOT:$line\"; exit 3",
506 ),
507 );
508 let draft = draft(&application, folder.path());
509
510 assert_eq!(
511 start(&application, draft.workflow_id, " -fix it "),
512 CommandDisposition::Accepted
513 );
514
515 let agent = workflow(&application);
516 assert_eq!(agent.kind, WorkflowKind::SingleAgent);
517 assert_eq!(agent.harness, Some(HarnessId::Pi));
518 assert_ne!(agent.terminal_id, draft.terminal_id);
519 let mut output = Vec::new();
520 wait_until(|| output_contains(&application, &mut output, "ARGS:--|-fix it"));
521 application
522 .write_terminal_input(agent.terminal_id, b"hello\n")
523 .unwrap();
524 wait_until(|| output_contains(&application, &mut output, "GOT:hello"));
525 wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
527 let trace = application
528 .workflow_trace(draft.workflow_id, None, 10)
529 .unwrap();
530 assert_eq!(trace.spans.len(), 1);
531 let agent_span = trace
532 .spans
533 .iter()
534 .find(|span| span.terminal_id == Some(agent.terminal_id))
535 .unwrap();
536 assert_eq!(agent_span.status, crate::TraceSpanStatus::Exited);
537 assert!(!agent_span.is_live);
538 let lane = trace
539 .lanes
540 .iter()
541 .find(|lane| lane.lane_id == agent_span.lane_id)
542 .unwrap();
543 assert!(lane.is_agent);
544 assert_eq!(lane.role.as_deref(), Some("agent"));
545 assert_eq!(lane.harness.as_deref(), Some("pi"));
546 let events = application
547 .trace_events(agent_span.span_id, None, 10)
548 .unwrap()
549 .events;
550 assert_eq!(events.len(), 2);
551 assert_eq!(events[0].kind, crate::TraceEventKind::ProcessStarted);
552 assert_eq!(events[1].kind, crate::TraceEventKind::ProcessExited);
553 assert!(events[1].message.contains("code 3"));
554 assert!(events[1].anchor.as_ref().unwrap().byte_offset > 0);
555 assert!(
556 events
557 .iter()
558 .all(|event| !event.message.contains("fix it") && !event.message.contains("GOT:"))
559 );
560 assert!(
561 trace
562 .spans
563 .iter()
564 .all(|span| span.terminal_id != Some(draft.terminal_id))
565 );
566 assert!(trace.lanes.iter().all(|lane| lane.is_agent));
567 }
568
569 #[test]
570 fn an_agent_started_from_a_restored_draft_keeps_live_terminal_ownership() {
571 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
572 let app = application(folder.path(), bin.path(), Some("read line"));
573 let original = draft(&app, folder.path());
574 app.handle_command(RequestId(20), Command::CloseFolder)
575 .unwrap();
576 app.handle_command(
577 RequestId(21),
578 Command::OpenFolder {
579 path: folder.path().to_owned(),
580 },
581 )
582 .unwrap();
583 assert!(workflow(&app).restored);
584 assert_eq!(
585 start(&app, original.workflow_id, ""),
586 CommandDisposition::Accepted
587 );
588 let live = workflow(&app);
589 assert!(!live.restored);
590 assert_eq!(live.terminal_history, []);
591 assert_eq!(live.terminal_ids(), vec![live.terminal_id]);
592 app.write_terminal_input(live.terminal_id, b"exit\n")
593 .unwrap();
594 wait_until(|| workflow(&app).status == WorkflowStatus::Exited);
595 app.handle_command(
596 RequestId(22),
597 Command::CloseWorkflow {
598 workflow_id: live.workflow_id,
599 },
600 )
601 .unwrap();
602 assert!(
603 app.write_terminal_input(live.terminal_id, b"late\n")
604 .is_err()
605 );
606 }
607
608 #[test]
609 fn antigravity_starts_interactively_with_options_and_accepts_follow_up_input() {
610 for prompt in ["", "-fix it\nKeep the terminal open"] {
611 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
612 let application = application(
613 folder.path(),
614 bin.path(),
615 Some(
616 "printf 'ARG:%s\\n' \"$@\"; echo READY; IFS= read -r line; printf 'GOT:%s\\n' \"$line\"",
617 ),
618 );
619 std::fs::rename(bin.path().join("pi"), bin.path().join("agy")).unwrap();
620 let draft = draft(&application, folder.path());
621 assert_eq!(
622 application
623 .handle_command(
624 RequestId(3),
625 Command::StartAgent {
626 workflow_id: draft.workflow_id,
627 harness: HarnessId::Antigravity,
628 model: Some("gemini-3.8-flash-high".into()),
629 effort: Some("high".into()),
630 yolo: true,
631 prompt: prompt.into(),
632 size: SIZE,
633 },
634 )
635 .unwrap()
636 .disposition,
637 CommandDisposition::Accepted
638 );
639 let agent = workflow(&application);
640 assert_eq!(agent.harness, Some(HarnessId::Antigravity));
641 assert_eq!(agent.name, "Antigravity");
642 let mut output = Vec::new();
643 wait_until(|| output_contains(&application, &mut output, "READY"));
644 let arguments = String::from_utf8_lossy(&output);
645 for argument in [
646 "--dangerously-skip-permissions",
647 "--model",
648 "gemini-3.8-flash-high",
649 "--effort",
650 "high",
651 ] {
652 assert!(arguments.contains(&format!("ARG:{argument}\r\n")));
653 }
654 if prompt.is_empty() {
655 assert!(!arguments.contains("--prompt-interactive"));
656 } else {
657 assert!(
658 arguments
659 .contains("ARG:--prompt-interactive=-fix it\r\nKeep the terminal open")
660 );
661 }
662 assert!(!arguments.contains("ARG:--print"));
663 application
664 .write_terminal_input(agent.terminal_id, b"follow up\n")
665 .unwrap();
666 wait_until(|| output_contains(&application, &mut output, "GOT:follow up"));
667 wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
668 let trace = application
669 .workflow_trace(draft.workflow_id, None, 10)
670 .unwrap();
671 let lane = trace.lanes.iter().find(|lane| lane.is_agent).unwrap();
672 assert_eq!(lane.harness.as_deref(), Some("Antigravity"));
673 let span = trace
674 .spans
675 .iter()
676 .find(|span| span.lane_id == lane.lane_id)
677 .unwrap();
678 assert_eq!(span.status, crate::TraceSpanStatus::Exited);
679 assert_eq!(
680 trace.spans.len(),
681 1,
682 "The lifecycle span remains without prompt hooks"
683 );
684 }
685 }
686
687 #[test]
688 fn omp_launch_keeps_prompts_literal_and_accepts_follow_up_input() {
689 for prompt in ["", "models", "@README.md", "-fix it"] {
690 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
691 let application = application(
692 folder.path(),
693 bin.path(),
694 Some(
695 "printf 'ARG:%s\\n' \"$@\"; echo READY; IFS= read -r line; printf 'GOT:%s\\n' \"$line\"",
696 ),
697 );
698 std::fs::rename(bin.path().join("pi"), bin.path().join("omp")).unwrap();
699 let draft = draft(&application, folder.path());
700 assert_eq!(
701 application
702 .handle_command(
703 RequestId(3),
704 Command::StartAgent {
705 workflow_id: draft.workflow_id,
706 harness: HarnessId::Omp,
707 model: Some("local/model".into()),
708 effort: Some("high".into()),
709 yolo: true,
710 prompt: prompt.into(),
711 size: SIZE,
712 }
713 )
714 .unwrap()
715 .disposition,
716 CommandDisposition::Accepted
717 );
718 let agent = workflow(&application);
719 assert_eq!(agent.harness, Some(HarnessId::Omp));
720 let mut output = Vec::new();
721 wait_until(|| output_contains(&application, &mut output, "READY"));
722 let arguments = String::from_utf8_lossy(&output);
723 assert!(arguments.starts_with("ARG:launch\r\nARG:--extension\r\n"));
724 for argument in [
725 "--auto-approve",
726 "--model",
727 "local/model",
728 "--thinking",
729 "high",
730 ] {
731 assert!(arguments.contains(&format!("ARG:{argument}\r\n")));
732 }
733 if prompt.is_empty() {
734 assert!(!arguments.contains("ARG:--\r\n"));
735 } else {
736 assert!(arguments.contains(&format!("ARG:--\r\nARG:{prompt}\r\n")));
737 }
738 assert!(!arguments.contains("ARG:--print"));
739 application
740 .write_terminal_input(agent.terminal_id, b"follow up\n")
741 .unwrap();
742 wait_until(|| output_contains(&application, &mut output, "GOT:follow up"));
743 wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
744 }
745 }
746
747 #[test]
748 fn opencode_launch_is_interactive_with_literal_prompts_model_variants_and_lifecycle_traces() {
749 for prompt in [
750 "",
751 "models",
752 "@README.md",
753 "-fix it\nKeep the terminal open",
754 ] {
755 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
756 let application = application(
757 folder.path(),
758 bin.path(),
759 Some(
760 "printf 'ARG:%s\\n' \"$@\"; echo READY; IFS= read -r line; printf 'GOT:%s\\n' \"$line\"",
761 ),
762 );
763 std::fs::rename(bin.path().join("pi"), bin.path().join("opencode")).unwrap();
764 let draft = draft(&application, folder.path());
765 assert_eq!(
766 application
767 .handle_command(
768 RequestId(3),
769 Command::StartAgent {
770 workflow_id: draft.workflow_id,
771 harness: HarnessId::Opencode,
772 model: Some("local/model".into()),
773 effort: Some("high".into()),
774 yolo: false,
775 prompt: prompt.into(),
776 size: SIZE,
777 }
778 )
779 .unwrap()
780 .disposition,
781 CommandDisposition::Accepted
782 );
783 let agent = workflow(&application);
784 assert_eq!(agent.harness, Some(HarnessId::Opencode));
785 assert_eq!(agent.name, "OpenCode");
786 let mut output = Vec::new();
787 wait_until(|| output_contains(&application, &mut output, "READY"));
788 let expected = if prompt.is_empty() {
789 "ARG:mini\r\nARG:--standalone\r\nARG:--model\r\nARG:local/model#high\r\nREADY\r\n"
790 .to_owned()
791 } else {
792 format!(
793 "ARG:mini\r\nARG:--standalone\r\nARG:--model\r\nARG:local/model#high\r\nARG:--prompt={}\r\nREADY\r\n",
794 prompt.replace('\n', "\r\n")
795 )
796 };
797 assert_eq!(String::from_utf8_lossy(&output), expected);
798 application
799 .write_terminal_input(agent.terminal_id, b"follow up\n")
800 .unwrap();
801 wait_until(|| output_contains(&application, &mut output, "GOT:follow up"));
802 wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
803 let trace = application
804 .workflow_trace(draft.workflow_id, None, 10)
805 .unwrap();
806 assert_eq!(trace.spans.len(), 1);
807 let lane = trace.lanes.iter().find(|lane| lane.is_agent).unwrap();
808 assert_eq!(lane.harness.as_deref(), Some("OpenCode"));
809 let span = trace
810 .spans
811 .iter()
812 .find(|span| span.lane_id == lane.lane_id)
813 .unwrap();
814 assert_eq!(span.status, crate::TraceSpanStatus::Exited);
815 }
816 }
817
818 #[test]
819 fn agent_allocation_waits_without_holding_application_state() {
820 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
821 let application = Arc::new(application(folder.path(), bin.path(), Some("read line")));
822 let draft = draft(&application, folder.path());
823 let stalled = application
824 .terminal_output
825 .stall_recording_worker(draft.terminal_id);
826 let starting = Arc::clone(&application);
827 let starter = thread::spawn(move || start(&starting, draft.workflow_id, "wait for input"));
828 wait_until(|| application.terminal_output.allocation_is_pending());
829 let (sent, received) = std::sync::mpsc::sync_channel(1);
830 let observing = Arc::clone(&application);
831 let observer = thread::spawn(move || {
832 let snapshot = observing.snapshot().unwrap();
833 observing.events_after(snapshot.sequence, 8).unwrap();
834 sent.send(()).unwrap();
835 });
836 received
837 .recv_timeout(Duration::from_secs(1))
838 .expect("state must remain available while agent allocation is stalled");
839 observer.join().unwrap();
840 assert!(!starter.is_finished());
841 drop(stalled);
842 assert_eq!(starter.join().unwrap(), CommandDisposition::Accepted);
843 }
844
845 #[test]
846 fn a_missing_harness_binary_is_rejected_and_keeps_the_draft() {
847 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
848 let application = application(folder.path(), bin.path(), None);
849 let draft = draft(&application, folder.path());
850
851 let CommandDisposition::Rejected { code, message } =
852 start(&application, draft.workflow_id, "hi")
853 else {
854 panic!("a missing binary should be rejected");
855 };
856
857 assert_eq!(code, "harnessNotFound");
858 assert!(message.contains("`pi`"), "{message}");
859 assert_eq!(workflow(&application), draft);
860 }
861
862 #[test]
863 fn an_agent_without_a_prompt_starts_interactively_and_only_from_a_draft() {
864 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
865 let application = application(
866 folder.path(),
867 bin.path(),
868 Some(
869 "for a in \"$@\"; do [ \"$a\" = -- ] && echo SEPARATOR; done; echo ARGS-DONE; sleep 30",
870 ),
871 );
872 let draft = draft(&application, folder.path());
873
874 assert_eq!(
875 start(&application, draft.workflow_id, " "),
876 CommandDisposition::Accepted
877 );
878 let mut output = Vec::new();
880 wait_until(|| output_contains(&application, &mut output, "ARGS-DONE"));
881 assert!(!String::from_utf8_lossy(&output).contains("SEPARATOR"));
882 assert!(matches!(
883 start(&application, draft.workflow_id, "again"),
884 CommandDisposition::Rejected { code, .. } if code == "workflowNotDraft"
885 ));
886 }
887
888 #[test]
889 fn the_agent_starts_with_its_chosen_model_and_effort_and_rejects_a_flag_as_a_model() {
890 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
891 let application = application(
892 folder.path(),
893 bin.path(),
894 Some("printf 'ARGS:%s\\n' \"$*\"; sleep 30"),
895 );
896 let draft = draft(&application, folder.path());
897 let start_with = |model: &str, effort: &str| {
898 application
899 .handle_command(
900 RequestId(3),
901 Command::StartAgent {
902 workflow_id: draft.workflow_id,
903 harness: HarnessId::Pi,
904 model: Some(model.to_owned()),
905 effort: Some(effort.to_owned()),
906 yolo: true,
908 prompt: String::new(),
909 size: SIZE,
910 },
911 )
912 .unwrap()
913 .disposition
914 };
915
916 assert!(matches!(
917 start_with("--help", "high"),
918 CommandDisposition::Rejected { code, .. } if code == "invalidModel"
919 ));
920 assert!(matches!(
921 start_with("local/m1", "High"),
922 CommandDisposition::Rejected { code, .. } if code == "invalidModel"
923 ));
924 assert_eq!(
925 start_with(" local/m1 ", "high"),
926 CommandDisposition::Accepted
927 );
928 let mut output = Vec::new();
929 wait_until(|| {
930 output_contains(
931 &application,
932 &mut output,
933 "--model local/m1 --thinking high",
934 )
935 });
936 }
937
938 #[test]
939 fn cancelling_stops_the_process_and_stays_cancelled() {
940 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
941 let application = application(
942 folder.path(),
943 bin.path(),
944 Some("trap '' HUP; echo ready; while :; do sleep 1; done"),
945 );
946 let draft = draft(&application, folder.path());
947 start(&application, draft.workflow_id, "go");
948 let mut output = Vec::new();
949 wait_until(|| output_contains(&application, &mut output, "ready"));
950
951 let receipt = application
952 .handle_command(
953 RequestId(4),
954 Command::CancelAgent {
955 workflow_id: draft.workflow_id,
956 },
957 )
958 .unwrap();
959
960 assert_eq!(receipt.disposition, CommandDisposition::Accepted);
961 let agent = workflow(&application);
962 assert_eq!(agent.status, WorkflowStatus::Cancelled);
963 let terminal_id = agent.terminal_id;
964 wait_until(|| {
965 application
966 .snapshot()
967 .unwrap()
968 .terminals
969 .iter()
970 .any(|terminal| {
971 terminal.terminal_id == terminal_id
972 && matches!(terminal.status, TerminalStatus::Exited(_))
973 })
974 });
975 assert_eq!(workflow(&application).status, WorkflowStatus::Cancelled);
976 let trace = application
977 .workflow_trace(draft.workflow_id, None, 10)
978 .unwrap();
979 let span = trace
980 .spans
981 .iter()
982 .find(|span| span.terminal_id == Some(terminal_id))
983 .unwrap();
984 assert_eq!(span.status, crate::TraceSpanStatus::Stopped);
985 let events = application
986 .trace_events(span.span_id, None, 10)
987 .unwrap()
988 .events;
989 assert_eq!(
990 events.len(),
991 2,
992 "the exit callback must not add a second ending"
993 );
994 assert_eq!(events[1].kind, crate::TraceEventKind::ProcessStopped);
995 assert!(events[1].message.contains("cancelled"));
996 assert!(matches!(
997 application
998 .handle_command(
999 RequestId(5),
1000 Command::CancelAgent {
1001 workflow_id: draft.workflow_id
1002 }
1003 )
1004 .unwrap()
1005 .disposition,
1006 CommandDisposition::Rejected { code, .. } if code == "agentNotRunning"
1007 ));
1008 }
1009
1010 #[test]
1011 fn restored_agents_keep_how_they_ended_and_only_running_ones_are_interrupted() {
1012 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1013 let data = tempfile::tempdir().unwrap();
1014 let ids = {
1015 let mut first =
1016 Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1017 first.harness_path = Some(OsString::from(format!(
1018 "{}:/bin:/usr/bin",
1019 bin.path().display()
1020 )));
1021 let binary = bin.path().join("pi");
1022 std::fs::write(
1023 &binary,
1024 "#!/bin/sh\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != -- ]; do shift; done\ncase \"$2\" in exit) exit 0;; *) trap '' HUP; sleep 30;; esac\n",
1025 )
1026 .unwrap();
1027 std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
1028 first
1029 .handle_command(
1030 RequestId(1),
1031 Command::OpenFolder {
1032 path: folder.path().to_owned(),
1033 },
1034 )
1035 .unwrap();
1036 let mut ids = Vec::new();
1037 for prompt in ["exit", "cancel", "run"] {
1038 let draft = draft_at(&first, folder.path());
1039 assert_eq!(
1040 start(&first, draft.workflow_id, prompt),
1041 CommandDisposition::Accepted
1042 );
1043 ids.push(draft.workflow_id);
1044 }
1045 first
1046 .handle_command(
1047 RequestId(9),
1048 Command::CancelAgent {
1049 workflow_id: ids[1],
1050 },
1051 )
1052 .unwrap();
1053 wait_until(|| status_of(&first, ids[0]) == WorkflowStatus::Exited);
1054 ids
1055 };
1056
1057 let second =
1058 Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1059
1060 for (id, expected) in ids.into_iter().zip([
1061 WorkflowStatus::Exited,
1062 WorkflowStatus::Cancelled,
1063 WorkflowStatus::Interrupted,
1064 ]) {
1065 let restored = second
1066 .snapshot()
1067 .unwrap()
1068 .workflows
1069 .workflows
1070 .into_iter()
1071 .find(|workflow| workflow.workflow_id == id)
1072 .unwrap();
1073 assert_eq!(restored.kind, WorkflowKind::SingleAgent);
1074 assert_eq!(restored.harness, Some(HarnessId::Pi));
1075 assert_eq!(restored.status, expected);
1076 assert!(restored.restored);
1077 let trace = second.workflow_trace(id, None, 10).unwrap();
1078 assert_eq!(trace.spans.len(), 1);
1079 let lane = trace.lanes.iter().find(|lane| lane.is_agent).unwrap();
1080 assert_eq!(lane.harness.as_deref(), Some("pi"));
1081 let span = trace
1082 .spans
1083 .iter()
1084 .find(|span| span.lane_id == lane.lane_id)
1085 .unwrap();
1086 assert_eq!(
1087 span.status,
1088 if expected == WorkflowStatus::Exited {
1089 crate::TraceSpanStatus::Exited
1090 } else {
1091 crate::TraceSpanStatus::Stopped
1092 }
1093 );
1094 assert!(!span.is_live);
1095 assert_eq!(
1096 second
1097 .trace_events(span.span_id, None, 10)
1098 .unwrap()
1099 .events
1100 .len(),
1101 2
1102 );
1103 assert_eq!(Some(restored.terminal_id), span.terminal_id);
1104 assert!(
1105 restored.terminal_ids().is_empty(),
1106 "agents are never relaunched"
1107 );
1108 assert_eq!(
1109 restored.terminal_history.last().unwrap().terminal_id,
1110 restored.terminal_id
1111 );
1112 }
1113 }
1114
1115 #[test]
1116 fn a_failed_agent_start_trace_keeps_the_draft_and_rolls_back_its_shell_ending() {
1117 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1118 let application = application(folder.path(), bin.path(), Some("sleep 30"));
1119 let draft = draft(&application, folder.path());
1120 application.lock_inner().unwrap().folders.store().execute_test_sql(
1121 "CREATE TRIGGER reject_start BEFORE INSERT ON trace_events WHEN NEW.kind = 'processStarted' BEGIN SELECT RAISE(FAIL, 'test start failure'); END;"
1122 );
1123 assert!(
1124 application
1125 .handle_command(
1126 RequestId(3),
1127 Command::StartAgent {
1128 model: None,
1129 effort: None,
1130 yolo: false,
1131 workflow_id: draft.workflow_id,
1132 harness: HarnessId::Pi,
1133 prompt: "go".to_owned(),
1134 size: SIZE,
1135 }
1136 )
1137 .is_err()
1138 );
1139 assert_eq!(workflow(&application), draft);
1140 let stored = application
1141 .lock_inner()
1142 .unwrap()
1143 .folders
1144 .read_store()
1145 .workflows(folder.path())
1146 .unwrap();
1147 assert_eq!(stored[0].kind, WorkflowKind::Draft);
1148 assert_eq!(stored[0].harness, None);
1149 let trace = application
1150 .workflow_trace(draft.workflow_id, None, 10)
1151 .unwrap();
1152 assert_eq!(trace.spans, []);
1153 assert_eq!(trace.lanes, []);
1154 let history = application
1155 .lock_inner()
1156 .unwrap()
1157 .folders
1158 .store()
1159 .terminal_history(draft.workflow_id)
1160 .unwrap();
1161 assert_eq!(history.len(), 1);
1162 assert_eq!(history[0].terminal_id, draft.terminal_id);
1163 assert_eq!(application.snapshot().unwrap().terminals.len(), 1);
1164 assert!(
1165 application
1166 .terminals
1167 .size(crate::TerminalId::from_value(draft.terminal_id.value() + 1))
1168 .is_none()
1169 );
1170 application
1171 .write_terminal_input(draft.terminal_id, b"echo alive\n")
1172 .unwrap();
1173 }
1174
1175 #[test]
1176 fn a_failed_cancel_trace_still_stops_the_agent_and_retries_the_cancellation() {
1177 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1178 let application = application(
1179 folder.path(),
1180 bin.path(),
1181 Some("trap '' HUP; echo ready; while :; do sleep 1; done"),
1182 );
1183 let draft = draft(&application, folder.path());
1184 start(&application, draft.workflow_id, "go");
1185 let agent = workflow(&application);
1186 let mut output = Vec::new();
1187 wait_until(|| output_contains(&application, &mut output, "ready"));
1188 application.lock_inner().unwrap().folders.store().execute_test_sql(
1189 "CREATE TRIGGER reject_trace BEFORE INSERT ON trace_events BEGIN SELECT RAISE(FAIL, 'test recording failure'); END;"
1190 );
1191 application
1192 .handle_command(
1193 RequestId(4),
1194 Command::CancelAgent {
1195 workflow_id: draft.workflow_id,
1196 },
1197 )
1198 .unwrap();
1199 wait_until(|| {
1200 application
1201 .snapshot()
1202 .unwrap()
1203 .terminals
1204 .iter()
1205 .any(|terminal| {
1206 terminal.terminal_id == agent.terminal_id
1207 && matches!(terminal.status, TerminalStatus::Exited(_))
1208 })
1209 });
1210 assert_eq!(workflow(&application).status, WorkflowStatus::Cancelled);
1211 assert_eq!(
1212 application
1213 .lock_inner()
1214 .unwrap()
1215 .pending_trace_endings
1216 .len(),
1217 1
1218 );
1219 application
1220 .lock_inner()
1221 .unwrap()
1222 .folders
1223 .store()
1224 .execute_test_sql("DROP TRIGGER reject_trace");
1225 application
1226 .handle_command(RequestId(5), Command::Ping)
1227 .unwrap();
1228 let trace = application
1229 .workflow_trace(draft.workflow_id, None, 10)
1230 .unwrap();
1231 let span = trace
1232 .spans
1233 .iter()
1234 .find(|span| span.terminal_id == Some(agent.terminal_id))
1235 .unwrap();
1236 assert_eq!(span.status, crate::TraceSpanStatus::Stopped);
1237 let events = application
1238 .trace_events(span.span_id, None, 10)
1239 .unwrap()
1240 .events;
1241 assert_eq!(events.len(), 2);
1242 assert_eq!(events[1].kind, crate::TraceEventKind::ProcessStopped);
1243 assert!(events[1].message.contains("cancelled"));
1244 }
1245
1246 #[test]
1247 fn starting_replaces_the_placeholder_shell_and_keeps_its_size() {
1248 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1249 let application = application(folder.path(), bin.path(), Some("stty size; sleep 30"));
1250 let draft = draft(&application, folder.path());
1251 application
1252 .resize_terminal(
1253 draft.terminal_id,
1254 TerminalSize {
1255 rows: 37,
1256 columns: 101,
1257 pixel_width: 1_010,
1258 pixel_height: 740,
1259 },
1260 )
1261 .unwrap();
1262
1263 start(&application, draft.workflow_id, "go");
1264
1265 assert!(
1266 application
1267 .write_terminal_input(draft.terminal_id, b"x")
1268 .is_err(),
1269 "the draft's placeholder shell should be closed"
1270 );
1271 let mut output = Vec::new();
1272 wait_until(|| output_contains(&application, &mut output, "37 101"));
1273 }
1274
1275 #[test]
1276 fn a_configured_agent_cannot_be_activated_as_a_terminal_and_can_still_be_cancelled() {
1277 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1278 let application = application(folder.path(), bin.path(), Some("trap '' HUP; sleep 30"));
1279 let draft = draft(&application, folder.path());
1280 start(&application, draft.workflow_id, "go");
1281
1282 let activation = application
1283 .handle_command(
1284 RequestId(4),
1285 Command::ActivateWorkflow {
1286 workflow_id: draft.workflow_id,
1287 },
1288 )
1289 .unwrap();
1290
1291 assert!(matches!(
1292 activation.disposition,
1293 CommandDisposition::Rejected { code, .. } if code == "workflowNotDraft"
1294 ));
1295 let agent = workflow(&application);
1296 assert_eq!(agent.kind, WorkflowKind::SingleAgent);
1297 assert_eq!(agent.harness, Some(HarnessId::Pi));
1298 assert_eq!(agent.status, WorkflowStatus::Running);
1299 let cancel = application
1300 .handle_command(
1301 RequestId(5),
1302 Command::CancelAgent {
1303 workflow_id: draft.workflow_id,
1304 },
1305 )
1306 .unwrap();
1307 assert_eq!(cancel.disposition, CommandDisposition::Accepted);
1308 }
1309
1310 #[test]
1311 fn cancelling_an_agent_that_already_exited_is_rejected_and_stays_exited() {
1312 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1313 let application = application(folder.path(), bin.path(), Some("exit 0"));
1314 let draft = draft(&application, folder.path());
1315 start(&application, draft.workflow_id, "go");
1316 wait_until(|| workflow(&application).status == WorkflowStatus::Exited);
1317
1318 let receipt = application
1319 .handle_command(
1320 RequestId(4),
1321 Command::CancelAgent {
1322 workflow_id: draft.workflow_id,
1323 },
1324 )
1325 .unwrap();
1326
1327 assert!(matches!(
1328 receipt.disposition,
1329 CommandDisposition::Rejected { code, .. } if code == "agentNotRunning"
1330 ));
1331 assert_eq!(workflow(&application).status, WorkflowStatus::Exited);
1332 }
1333
1334 #[test]
1335 fn a_failed_agent_start_leaves_the_draft_and_its_shell_untouched() {
1336 let (folder, bin) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
1337 let application = application(folder.path(), bin.path(), Some("sleep 30"));
1338 let draft = draft(&application, folder.path());
1339 std::fs::remove_dir_all(folder.path()).unwrap();
1341
1342 let disposition = start(&application, draft.workflow_id, "go");
1343
1344 assert!(matches!(
1345 disposition,
1346 CommandDisposition::Rejected { code, .. } if code == "agentStartFailed"
1347 ));
1348 assert_eq!(workflow(&application), draft);
1349 application
1350 .write_terminal_input(draft.terminal_id, b"x")
1351 .expect("the draft's shell should still be running");
1352 let stored = application
1353 .lock_inner()
1354 .unwrap()
1355 .folders
1356 .store()
1357 .workflows(folder.path())
1358 .unwrap();
1359 assert_eq!(stored.len(), 1);
1360 assert_eq!(stored[0].kind, WorkflowKind::Draft);
1361 assert_eq!(stored[0].harness, None);
1362 }
1363}