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