Skip to main content

handshake_core/application/
agents.rs

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
16/// Finds a harness binary on `test_path` alone when tests give one, otherwise on the login
17/// shell's `PATH` and then the app's.
18fn 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    // An explicit override searches only that path, including for UI-test stub harnesses.
28    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    /// Finds a harness binary. The login shell's `PATH` is looked up once in the background, so
42    /// this only waits on that at the first start after launch.
43    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    /// Starts listing the models `harness` offers, read from its own CLI on another thread, so a
57    /// slow harness never holds up commands or terminals. Poll the request for the result.
58    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    /// The folder of a draft workflow's session, or the rejection to send if it isn't a draft.
79    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    /// Turns a draft workflow into a single agent: starts the harness in its own PTY, with
112    /// `prompt` if there is one, then replaces the draft's placeholder shell with it.
113    #[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        // Transcript allocation may wait on disk; lifetime commands keep this draft valid while
145        // application state remains available. Hold state again for startup and exit-event order.
146        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        // Start the agent at the size its draft terminal has now, as the view has already fitted it.
195        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            // Return promptly so the UI can answer the agent's startup terminal queries before
283            // they time out. Reaping the old shell includes a hang-up grace period.
284            let _ = self.terminals.close_in_background(placeholder);
285        }
286        Ok(CommandDisposition::Accepted)
287    }
288
289    /// Stops a running agent's process. The workflow stays open as cancelled, with its output.
290    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        // The exit callback keeps the cancelled status, so the process ending isn't reported as
340        // a crash or as finished work.
341        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    /// An application whose `pi` harness is `script`, or is missing when `script` is `None`.
374    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        // A nonzero exit is a finished process, never a successful or cancelled workflow.
621        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        // Hook flags may still be passed, but no prompt separator or prompt follows them.
974        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                        // pi has no permission prompts, so this adds no flag.
1002                        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        // The harness is found, but the folder is gone by the time the agent would start in it.
1435        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}