Skip to main content

leviath_cli/commands/
ctl.rs

1//! `lev msg` / `lev cancel` / `lev pause` / `lev resume` - control operations
2//! on a running agent in the shared-world daemon.
3//!
4//! Each sends a control request over the daemon socket and reports the boolean
5//! outcome. The request/response cores are tested here; the socket-path
6//! resolution + connect live in the binary behind [`crate::dispatch::RiskyExecutors`].
7
8use anyhow::bail;
9use leviath_core::interaction::{
10    ApprovalScope, InteractionKind, InteractionRequest, InteractionResponse,
11};
12use leviath_runtime::control_socket::{ControlClient, ControlRequest, ControlResponse};
13
14/// Arguments for `lev msg`.
15#[derive(clap::Args, Debug, Clone)]
16pub struct MsgArgs {
17    /// The target agent id.
18    pub agent_id: String,
19    /// The message to deliver.
20    pub content: String,
21}
22
23/// Arguments for `lev cancel`.
24#[derive(clap::Args, Debug, Clone)]
25pub struct CancelArgs {
26    /// The run id to cancel.
27    pub run_id: String,
28    /// Terminate the run's on-disk state directly, without asking the daemon.
29    ///
30    /// Use when the daemon is gone or unresponsive. The run is recorded
31    /// `Cancelled` so nothing lists it as live; if a daemon is in fact still
32    /// driving it, restart the daemon so it picks up the new state.
33    #[arg(long)]
34    pub force: bool,
35}
36
37/// Arguments for `lev pause`.
38#[derive(clap::Args, Debug, Clone)]
39pub struct PauseArgs {
40    /// The run id to pause.
41    pub run_id: String,
42}
43
44/// Arguments for `lev resume`.
45#[derive(clap::Args, Debug, Clone)]
46pub struct ResumeArgs {
47    /// The run id to resume.
48    pub run_id: String,
49}
50
51/// Arguments for `lev respond` - answer a pending `ask_user` interaction the
52/// daemon is holding, or (with no `request_id`) list the open interactions.
53#[derive(clap::Args, Debug, Clone)]
54pub struct RespondArgs {
55    /// The interaction request id to answer. Omit to list open interactions.
56    pub request_id: Option<String>,
57    /// Free-text (or edited) answer value.
58    pub value: Option<String>,
59    /// Answer a multiple-choice interaction by 0-based option index.
60    #[arg(long)]
61    pub choice: Option<usize>,
62    /// Approve a tool-approval / confirm interaction.
63    #[arg(long, conflicts_with = "deny")]
64    pub approve: bool,
65    /// Deny a tool-approval / confirm interaction.
66    #[arg(long)]
67    pub deny: bool,
68    /// With `--approve`, allow the tool for the rest of the session.
69    #[arg(long)]
70    pub session: bool,
71    /// Report open interactions (or the outcome of answering one) as JSON.
72    /// This is how an unattended caller finds the questions it has to answer.
73    #[arg(long)]
74    pub json: bool,
75}
76
77/// One open interaction in `lev respond --json`.
78///
79/// The whole request rather than the four fields the prose listing has room
80/// for: `tool_arguments` and `body` are exactly what a caller deciding whether
81/// to approve needs, and neither appears in the human listing.
82#[derive(serde::Serialize)]
83struct OpenInteraction<'a> {
84    /// The agent holding the question, for a caller polling several runs.
85    agent_id: &'a str,
86    #[serde(flatten)]
87    request: &'a InteractionRequest,
88}
89
90/// Send `request` and report the boolean outcome: `ok` prints `applied_msg`, a
91/// `false` outcome the `not_found_msg`. A non-`Ok` response or a connect failure
92/// is an error.
93async fn send_bool(
94    client: &ControlClient,
95    request: ControlRequest,
96    applied_msg: &str,
97    not_found_msg: &str,
98) -> anyhow::Result<()> {
99    match client.request(&request).await {
100        Ok(ControlResponse::Ok { ok: true }) => {
101            println!("{applied_msg}");
102            Ok(())
103        }
104        Ok(ControlResponse::Ok { ok: false }) => bail!("{not_found_msg}"),
105        Ok(other) => bail!("unexpected daemon response: {other:?}"),
106        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
107    }
108}
109
110/// `lev msg`: deliver a message to a running agent.
111pub async fn send_message(client: &ControlClient, args: &MsgArgs) -> anyhow::Result<()> {
112    send_bool(
113        client,
114        ControlRequest::Message {
115            agent_id: args.agent_id.clone(),
116            content: args.content.clone(),
117            target_region: None,
118        },
119        "message delivered",
120        "no agent accepted the message",
121    )
122    .await
123}
124
125/// `lev pause`: park a run. The daemon refuses (`ok: false`) when the run does
126/// not exist or is not in a pausable state (waiting on input, or finished).
127pub async fn pause_run(client: &ControlClient, args: &PauseArgs) -> anyhow::Result<()> {
128    send_bool(
129        client,
130        ControlRequest::Pause {
131            run_id: args.run_id.clone(),
132        },
133        "paused",
134        "no such run, or it is not pausable in its current state",
135    )
136    .await
137}
138
139/// `lev resume`: un-pause a run.
140pub async fn resume_run(client: &ControlClient, args: &ResumeArgs) -> anyhow::Result<()> {
141    send_bool(
142        client,
143        ControlRequest::Resume {
144            run_id: args.run_id.clone(),
145        },
146        "resumed",
147        "no such run, or it is not paused",
148    )
149    .await
150}
151
152/// `lev cancel`: cancel a run.
153///
154/// A kill must always be possible, so this never depends on the daemon being
155/// reachable. `--force` goes straight to the run's on-disk state; otherwise the
156/// daemon is asked first (it can also stop the work, not just record the
157/// outcome) and the on-disk write is the fallback when it can't be reached or
158/// doesn't answer in time.
159pub async fn cancel_run(client: &ControlClient, args: &CancelArgs) -> anyhow::Result<()> {
160    if args.force {
161        return report_forced(
162            crate::runstate::force_cancel(&args.run_id),
163            &args.run_id,
164            None,
165        );
166    }
167    match client
168        .request(&ControlRequest::Cancel {
169            run_id: args.run_id.clone(),
170        })
171        .await
172    {
173        Ok(ControlResponse::Ok { ok: true }) => {
174            println!("cancelled");
175            Ok(())
176        }
177        Ok(ControlResponse::Ok { ok: false }) => bail!("no such run"),
178        Ok(other) => bail!("unexpected daemon response: {other:?}"),
179        // The daemon is down, wedged, or too busy to answer. Terminate the run on
180        // disk ourselves rather than leave the user with nothing.
181        Err(e) => report_forced(
182            crate::runstate::force_cancel(&args.run_id),
183            &args.run_id,
184            Some(e),
185        ),
186    }
187}
188
189/// Report the outcome of an on-disk cancel. `daemon_error` is set when this was
190/// a fallback rather than an explicit `--force`, and is included so the user
191/// knows why the daemon wasn't used.
192fn report_forced(
193    outcome: crate::runstate::ForceCancelOutcome,
194    run_id: &str,
195    daemon_error: Option<std::io::Error>,
196) -> anyhow::Result<()> {
197    use crate::runstate::ForceCancelOutcome as O;
198    let why = match &daemon_error {
199        Some(e) => format!(" (the daemon did not answer: {e})"),
200        None => String::new(),
201    };
202    match outcome {
203        O::Terminated => {
204            println!(
205                "cancelled '{run_id}' on disk{why}; if a daemon is still running, \
206                 restart it so it picks up the change"
207            );
208            Ok(())
209        }
210        O::AlreadyTerminal => {
211            println!("'{run_id}' had already finished; nothing to cancel");
212            Ok(())
213        }
214        O::NoSuchRun => match daemon_error {
215            Some(e) => bail!(
216                "the leviath daemon is not reachable ({e}), and there is no run '{run_id}' on disk"
217            ),
218            None => bail!("no such run"),
219        },
220        O::WriteFailed => bail!("could not write '{run_id}' metadata to record the cancel"),
221    }
222}
223
224/// A short human label for an interaction kind (used by the `lev respond` list).
225fn kind_label(kind: &InteractionKind) -> &'static str {
226    match kind {
227        InteractionKind::FreeText => "free-text",
228        InteractionKind::MultipleChoice => "choice",
229        InteractionKind::Confirm => "confirm",
230        InteractionKind::ToolApproval => "tool-approval",
231        InteractionKind::EditText => "edit-text",
232    }
233}
234
235/// Render one open interaction as a multi-line listing entry.
236fn format_interaction(agent_id: &str, req: &InteractionRequest) -> String {
237    let mut s = format!(
238        "{}  [{}]  agent={}  stage={}\n  {}",
239        req.id,
240        kind_label(&req.kind),
241        agent_id,
242        req.stage_name,
243        req.prompt
244    );
245    for (i, opt) in req.options.iter().enumerate() {
246        s.push_str(&format!("\n    {i}) {opt}"));
247    }
248    if let Some(tool) = &req.tool_name {
249        s.push_str(&format!("\n    tool: {tool}"));
250    }
251    s
252}
253
254/// Build the [`InteractionResponse`] implied by the CLI flags. Approve/deny wins,
255/// then an explicit `--choice`, otherwise a free-text value (empty if omitted).
256fn build_response(request_id: &str, args: &RespondArgs) -> InteractionResponse {
257    if args.approve || args.deny {
258        let scope = if args.session {
259            ApprovalScope::Session
260        } else {
261            ApprovalScope::Once
262        };
263        InteractionResponse::approval(request_id, args.approve, scope)
264    } else if let Some(index) = args.choice {
265        InteractionResponse::choice(request_id, index)
266    } else {
267        InteractionResponse::text(request_id, args.value.clone().unwrap_or_default())
268    }
269}
270
271/// List the interactions the daemon is currently holding.
272async fn list_interactions(client: &ControlClient, json: bool) -> anyhow::Result<()> {
273    match client.request(&ControlRequest::ListInteractions).await {
274        Ok(ControlResponse::Interactions { interactions }) => {
275            if json {
276                let open: Vec<OpenInteraction<'_>> = interactions
277                    .iter()
278                    .map(|(agent_id, request)| OpenInteraction { agent_id, request })
279                    .collect();
280                // Nothing open is an empty array, not a sentence: a caller
281                // polling this branches on length, not on prose.
282                println!(
283                    "{}",
284                    serde_json::to_string_pretty(&open).expect("an interaction listing serializes")
285                );
286                return Ok(());
287            }
288            if interactions.is_empty() {
289                println!("no open interactions");
290            } else {
291                for (agent_id, req) in &interactions {
292                    println!("{}", format_interaction(agent_id, req));
293                }
294            }
295            Ok(())
296        }
297        Ok(other) => bail!("unexpected daemon response: {other:?}"),
298        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
299    }
300}
301
302/// `lev respond`: answer a pending interaction, or list open ones when no
303/// `request_id` is given.
304pub async fn respond(client: &ControlClient, args: &RespondArgs) -> anyhow::Result<()> {
305    match &args.request_id {
306        None => list_interactions(client, args.json).await,
307        Some(request_id) => {
308            // A failed answer stays an error (non-zero exit plus the message on
309            // stderr), so `--json` only changes the success line.
310            let applied = match args.json {
311                // Serialized, not interpolated: a request id carrying a quote
312                // would otherwise emit JSON that does not parse.
313                true => {
314                    serde_json::json!({ "answered": true, "request_id": request_id }).to_string()
315                }
316                false => "answered".to_string(),
317            };
318            send_bool(
319                client,
320                ControlRequest::AnswerInteraction {
321                    response: build_response(request_id, args),
322                },
323                &applied,
324                "no such open interaction",
325            )
326            .await
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
335    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
336    use tokio::task::JoinHandle;
337
338    /// Bind a control listener at a fresh id under `dir` and serve one canned
339    /// response, returning the id clients connect to and the server task.
340    fn fake_daemon(dir: &std::path::Path, response_line: String) -> (ControlId, JoinHandle<()>) {
341        let id = control_id(dir);
342        let mut listener = bind_control_listener(&id).unwrap();
343        let handle = tokio::spawn(async move {
344            let stream = listener
345                .accept()
346                .await
347                .expect("accept succeeds")
348                .expect("our own connection is admitted");
349            let (read_half, mut write_half) = tokio::io::split(stream);
350            let mut lines = BufReader::new(read_half).lines();
351            let _request = lines.next_line().await.unwrap();
352            write_half
353                .write_all(response_line.as_bytes())
354                .await
355                .unwrap();
356            write_half.write_all(b"\n").await.unwrap();
357        });
358        (id, handle)
359    }
360
361    /// Run `op` against a fake daemon that replies `response_line`.
362    async fn with_daemon<F, Fut>(response_line: impl Into<String>, op: F) -> anyhow::Result<()>
363    where
364        F: FnOnce(ControlClient) -> Fut,
365        Fut: std::future::Future<Output = anyhow::Result<()>>,
366    {
367        let dir = tempfile::tempdir().unwrap();
368        let (id, server) = fake_daemon(dir.path(), response_line.into());
369        let result = op(ControlClient::new(id)).await;
370        server.await.unwrap();
371        result
372    }
373
374    fn msg_args() -> MsgArgs {
375        MsgArgs {
376            agent_id: "a".to_string(),
377            content: "hi".to_string(),
378        }
379    }
380
381    #[tokio::test]
382    async fn message_applied() {
383        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
384            send_message(&c, &msg_args()).await
385        })
386        .await;
387        assert!(r.is_ok());
388    }
389
390    #[tokio::test]
391    async fn message_not_delivered() {
392        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
393            send_message(&c, &msg_args()).await
394        })
395        .await;
396        assert!(r.unwrap_err().to_string().contains("no agent accepted"));
397    }
398
399    #[tokio::test]
400    async fn pause_applied() {
401        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
402            pause_run(
403                &c,
404                &PauseArgs {
405                    run_id: "r".to_string(),
406                },
407            )
408            .await
409        })
410        .await;
411        assert!(r.is_ok());
412    }
413
414    #[tokio::test]
415    async fn pause_refused() {
416        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
417            pause_run(
418                &c,
419                &PauseArgs {
420                    run_id: "r".to_string(),
421                },
422            )
423            .await
424        })
425        .await;
426        assert!(r.unwrap_err().to_string().contains("not pausable"));
427    }
428
429    #[tokio::test]
430    async fn resume_applied() {
431        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
432            resume_run(
433                &c,
434                &ResumeArgs {
435                    run_id: "r".to_string(),
436                },
437            )
438            .await
439        })
440        .await;
441        assert!(r.is_ok());
442    }
443
444    #[tokio::test]
445    async fn resume_refused() {
446        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
447            resume_run(
448                &c,
449                &ResumeArgs {
450                    run_id: "r".to_string(),
451                },
452            )
453            .await
454        })
455        .await;
456        assert!(r.unwrap_err().to_string().contains("not paused"));
457    }
458
459    #[tokio::test]
460    async fn cancel_applied() {
461        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
462            cancel_run(
463                &c,
464                &CancelArgs {
465                    run_id: "r".to_string(),
466                    force: false,
467                },
468            )
469            .await
470        })
471        .await;
472        assert!(r.is_ok());
473    }
474
475    #[tokio::test]
476    async fn cancel_unknown_run() {
477        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
478            cancel_run(
479                &c,
480                &CancelArgs {
481                    run_id: "r".to_string(),
482                    force: false,
483                },
484            )
485            .await
486        })
487        .await;
488        assert!(r.unwrap_err().to_string().contains("no such run"));
489    }
490
491    #[tokio::test]
492    async fn unexpected_response_is_an_error() {
493        // Both the `send_bool` path (`lev msg`) and `cancel_run`'s own match
494        // reject a response shape they didn't ask for.
495        let r = with_daemon(r#"{"result":"spawned","run_id":"x"}"#, |c| async move {
496            send_message(&c, &msg_args()).await
497        })
498        .await;
499        assert!(r.unwrap_err().to_string().contains("unexpected"));
500
501        let r = with_daemon(r#"{"result":"spawned","run_id":"x"}"#, |c| async move {
502            cancel_run(
503                &c,
504                &CancelArgs {
505                    run_id: "r".to_string(),
506                    force: false,
507                },
508            )
509            .await
510        })
511        .await;
512        assert!(r.unwrap_err().to_string().contains("unexpected"));
513    }
514
515    /// `lev msg` has no on-disk fallback - an unreachable daemon is simply an
516    /// error, unlike `lev cancel`.
517    #[tokio::test]
518    async fn message_to_an_unreachable_daemon_is_an_error() {
519        let dir = tempfile::tempdir().unwrap();
520        let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
521        let err = send_message(&client, &msg_args()).await.unwrap_err();
522        assert!(err.to_string().contains("not reachable"));
523    }
524
525    /// A run directory that cannot be rewritten is reported as such, rather than
526    /// as a successful cancel.
527    #[tokio::test]
528    async fn forcing_a_run_whose_metadata_cannot_be_written_reports_the_failure() {
529        crate::runstate::with_isolated_runs_dir_async("ctl-force-unwritable", |_base| async {
530            let dir = crate::runstate::run_dir("blocked-1");
531            std::fs::create_dir_all(dir.join("meta.json")).unwrap();
532
533            let err = cancel_run(
534                &ControlClient::new(control_id(std::path::Path::new("/nonexistent"))),
535                &CancelArgs {
536                    run_id: "blocked-1".to_string(),
537                    force: true,
538                },
539            )
540            .await
541            .unwrap_err();
542            assert!(err.to_string().contains("could not write"), "got: {err}");
543        })
544        .await;
545    }
546
547    #[tokio::test]
548    async fn not_reachable_is_an_error() {
549        let dir = tempfile::tempdir().unwrap();
550        let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
551        let err = cancel_run(
552            &client,
553            &CancelArgs {
554                run_id: "r".to_string(),
555                force: false,
556            },
557        )
558        .await
559        .unwrap_err();
560        assert!(err.to_string().contains("not reachable"));
561    }
562
563    /// Write a live-looking run into the (isolated) runs dir.
564    fn seed_live_run(run_id: &str) {
565        crate::runstate::create_run(&crate::runstate::RunMeta {
566            status: crate::runstate::RunStatus::Running,
567            ..crate::runstate::RunMeta::new(
568                run_id.into(),
569                "a".into(),
570                "/p".into(),
571                "t".into(),
572                None,
573                "/w".into(),
574                1,
575            )
576        })
577        .unwrap();
578    }
579
580    fn status_of(run_id: &str) -> crate::runstate::RunStatus {
581        crate::runstate::read_meta(run_id).unwrap().status
582    }
583
584    /// `--force` never contacts the daemon, so a kill stays possible when the
585    /// daemon is dead, wedged, or was never started.
586    #[tokio::test]
587    async fn force_cancels_on_disk_without_a_daemon() {
588        crate::runstate::with_isolated_runs_dir_async("ctl-force-cancel", |_base| async {
589            seed_live_run("stuck-1");
590            let dir = tempfile::tempdir().unwrap();
591            // A socket path with nothing listening on it.
592            let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
593
594            cancel_run(
595                &client,
596                &CancelArgs {
597                    run_id: "stuck-1".to_string(),
598                    force: true,
599                },
600            )
601            .await
602            .expect("forced cancel succeeds with no daemon");
603
604            assert_eq!(status_of("stuck-1"), crate::runstate::RunStatus::Cancelled);
605        })
606        .await;
607    }
608
609    /// Without `--force`, an unreachable daemon falls back to the on-disk write
610    /// rather than leaving the user with an error and a run still marked live.
611    #[tokio::test]
612    async fn an_unreachable_daemon_falls_back_to_cancelling_on_disk() {
613        crate::runstate::with_isolated_runs_dir_async("ctl-fallback-cancel", |_base| async {
614            seed_live_run("stuck-2");
615            let dir = tempfile::tempdir().unwrap();
616            let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
617
618            cancel_run(
619                &client,
620                &CancelArgs {
621                    run_id: "stuck-2".to_string(),
622                    force: false,
623                },
624            )
625            .await
626            .expect("the fallback succeeds");
627
628            assert_eq!(status_of("stuck-2"), crate::runstate::RunStatus::Cancelled);
629        })
630        .await;
631    }
632
633    /// Forcing a run that already finished is reported, not treated as a failure.
634    #[tokio::test]
635    async fn forcing_an_already_finished_run_is_not_an_error() {
636        crate::runstate::with_isolated_runs_dir_async("ctl-force-terminal", |_base| async {
637            crate::runstate::create_run(&crate::runstate::RunMeta {
638                status: crate::runstate::RunStatus::Complete,
639                ..crate::runstate::RunMeta::new(
640                    "done-1".into(),
641                    "a".into(),
642                    "/p".into(),
643                    "t".into(),
644                    None,
645                    "/w".into(),
646                    1,
647                )
648            })
649            .unwrap();
650
651            cancel_run(
652                &ControlClient::new(control_id(std::path::Path::new("/nonexistent"))),
653                &CancelArgs {
654                    run_id: "done-1".to_string(),
655                    force: true,
656                },
657            )
658            .await
659            .expect("already-finished is reported, not an error");
660
661            assert_eq!(
662                status_of("done-1"),
663                crate::runstate::RunStatus::Complete,
664                "and the recorded outcome is left intact"
665            );
666        })
667        .await;
668    }
669
670    /// Forcing an id that names no run at all is still an honest failure.
671    #[tokio::test]
672    async fn forcing_an_unknown_run_reports_no_such_run() {
673        crate::runstate::with_isolated_runs_dir_async("ctl-force-missing", |_base| async {
674            let err = cancel_run(
675                &ControlClient::new(control_id(std::path::Path::new("/nonexistent"))),
676                &CancelArgs {
677                    run_id: "never-existed".to_string(),
678                    force: true,
679                },
680            )
681            .await
682            .unwrap_err();
683            assert!(err.to_string().contains("no such run"), "got: {err}");
684        })
685        .await;
686    }
687
688    // ─── lev respond ──────────────────────────────────────────────────────────
689
690    fn respond_args() -> RespondArgs {
691        RespondArgs {
692            request_id: Some("q1".to_string()),
693            value: None,
694            choice: None,
695            approve: false,
696            deny: false,
697            session: false,
698            json: false,
699        }
700    }
701
702    #[test]
703    fn build_response_free_text_uses_value_or_empty() {
704        let with_value = build_response(
705            "q1",
706            &RespondArgs {
707                value: Some("hello".to_string()),
708                ..respond_args()
709            },
710        );
711        assert_eq!(with_value, InteractionResponse::text("q1", "hello"));
712        // Missing value → empty string.
713        assert_eq!(
714            build_response("q1", &respond_args()),
715            InteractionResponse::text("q1", "")
716        );
717    }
718
719    #[test]
720    fn build_response_choice_selects_index() {
721        let r = build_response(
722            "q1",
723            &RespondArgs {
724                choice: Some(2),
725                ..respond_args()
726            },
727        );
728        assert_eq!(r, InteractionResponse::choice("q1", 2));
729    }
730
731    #[test]
732    fn build_response_approve_and_deny_and_session_scope() {
733        let approved = build_response(
734            "q1",
735            &RespondArgs {
736                approve: true,
737                ..respond_args()
738            },
739        );
740        assert_eq!(
741            approved,
742            InteractionResponse::approval("q1", true, ApprovalScope::Once)
743        );
744        let session = build_response(
745            "q1",
746            &RespondArgs {
747                approve: true,
748                session: true,
749                json: false,
750                ..respond_args()
751            },
752        );
753        assert_eq!(
754            session,
755            InteractionResponse::approval("q1", true, ApprovalScope::Session)
756        );
757        let denied = build_response(
758            "q1",
759            &RespondArgs {
760                deny: true,
761                ..respond_args()
762            },
763        );
764        assert_eq!(
765            denied,
766            InteractionResponse::approval("q1", false, ApprovalScope::Once)
767        );
768    }
769
770    #[test]
771    fn kind_label_covers_every_kind() {
772        for (kind, label) in [
773            (InteractionKind::FreeText, "free-text"),
774            (InteractionKind::MultipleChoice, "choice"),
775            (InteractionKind::Confirm, "confirm"),
776            (InteractionKind::ToolApproval, "tool-approval"),
777            (InteractionKind::EditText, "edit-text"),
778        ] {
779            assert_eq!(kind_label(&kind), label);
780        }
781    }
782
783    #[test]
784    fn format_interaction_renders_options_and_tool() {
785        let mut req = InteractionRequest::multiple_choice(
786            "q1",
787            "Pick",
788            vec!["a".to_string(), "b".to_string()],
789            "plan",
790        );
791        req.tool_name = Some("bash".to_string());
792        let out = format_interaction("agent-x", &req);
793        assert!(out.contains("q1  [choice]  agent=agent-x  stage=plan"));
794        assert!(out.contains("Pick"));
795        assert!(out.contains("0) a"));
796        assert!(out.contains("1) b"));
797        assert!(out.contains("tool: bash"));
798    }
799
800    #[tokio::test]
801    async fn respond_answers_an_interaction() {
802        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
803            respond(&c, &respond_args()).await
804        })
805        .await;
806        assert!(r.is_ok());
807    }
808
809    #[tokio::test]
810    async fn respond_reports_no_open_interaction() {
811        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
812            respond(&c, &respond_args()).await
813        })
814        .await;
815        assert!(r.unwrap_err().to_string().contains("no such open"));
816    }
817
818    // ─── --json ──────────────────────────────────────────────────────────
819
820    #[test]
821    fn open_interaction_serializes_the_agent_id_alongside_the_request() {
822        // `#[serde(flatten)]` is what puts `id` and `prompt` at the top level
823        // next to `agent_id`. Losing it would nest the request under a key no
824        // caller expects.
825        let mut request = InteractionRequest::multiple_choice(
826            "q1",
827            "Pick",
828            vec!["a".to_string(), "b".to_string()],
829            "plan",
830        );
831        request.tool_name = Some("bash".to_string());
832        let open = OpenInteraction {
833            agent_id: "agent-x",
834            request: &request,
835        };
836        let value: serde_json::Value =
837            serde_json::from_str(&serde_json::to_string(&open).unwrap()).unwrap();
838        assert_eq!(value["agent_id"], serde_json::json!("agent-x"));
839        assert_eq!(value["id"], serde_json::json!("q1"));
840        assert_eq!(value["stage_name"], serde_json::json!("plan"));
841        assert_eq!(value["options"], serde_json::json!(["a", "b"]));
842        assert_eq!(value["tool_name"], serde_json::json!("bash"));
843    }
844
845    #[tokio::test]
846    async fn respond_lists_open_interactions_as_json() {
847        let req = InteractionRequest::free_text("q1", "What now?", "plan", true);
848        let line = serde_json::to_string(&ControlResponse::Interactions {
849            interactions: vec![("agent-a".to_string(), req)],
850        })
851        .unwrap();
852        let r = with_daemon(line, |c| async move {
853            respond(
854                &c,
855                &RespondArgs {
856                    request_id: None,
857                    json: true,
858                    ..respond_args()
859                },
860            )
861            .await
862        })
863        .await;
864        assert!(r.is_ok());
865    }
866
867    #[tokio::test]
868    async fn respond_lists_nothing_open_as_json() {
869        let line = serde_json::to_string(&ControlResponse::Interactions {
870            interactions: Vec::new(),
871        })
872        .unwrap();
873        let r = with_daemon(line, |c| async move {
874            respond(
875                &c,
876                &RespondArgs {
877                    request_id: None,
878                    json: true,
879                    ..respond_args()
880                },
881            )
882            .await
883        })
884        .await;
885        assert!(r.is_ok());
886    }
887
888    #[tokio::test]
889    async fn respond_answers_an_interaction_as_json() {
890        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
891            respond(
892                &c,
893                &RespondArgs {
894                    json: true,
895                    ..respond_args()
896                },
897            )
898            .await
899        })
900        .await;
901        assert!(r.is_ok());
902    }
903
904    #[tokio::test]
905    async fn respond_lists_open_interactions() {
906        let req = InteractionRequest::free_text("q1", "What now?", "plan", true);
907        let line = serde_json::to_string(&ControlResponse::Interactions {
908            interactions: vec![("agent-a".to_string(), req)],
909        })
910        .unwrap();
911        let r = with_daemon(line, |c| async move {
912            respond(
913                &c,
914                &RespondArgs {
915                    request_id: None,
916                    ..respond_args()
917                },
918            )
919            .await
920        })
921        .await;
922        assert!(r.is_ok());
923    }
924
925    #[tokio::test]
926    async fn respond_lists_when_none_open() {
927        let line = serde_json::to_string(&ControlResponse::Interactions {
928            interactions: vec![],
929        })
930        .unwrap();
931        let r = with_daemon(line, |c| async move {
932            respond(
933                &c,
934                &RespondArgs {
935                    request_id: None,
936                    ..respond_args()
937                },
938            )
939            .await
940        })
941        .await;
942        assert!(r.is_ok());
943    }
944
945    #[tokio::test]
946    async fn respond_list_rejects_unexpected_response() {
947        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
948            respond(
949                &c,
950                &RespondArgs {
951                    request_id: None,
952                    ..respond_args()
953                },
954            )
955            .await
956        })
957        .await;
958        assert!(r.unwrap_err().to_string().contains("unexpected"));
959    }
960
961    #[tokio::test]
962    async fn respond_list_errors_when_daemon_absent() {
963        let dir = tempfile::tempdir().unwrap();
964        let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
965        let err = respond(
966            &client,
967            &RespondArgs {
968                request_id: None,
969                ..respond_args()
970            },
971        )
972        .await
973        .unwrap_err();
974        assert!(err.to_string().contains("not reachable"));
975    }
976}