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