Skip to main content

leviath_cli/commands/
ctl.rs

1//! `lev msg` / `lev cancel` - control operations on a running agent in the
2//! shared-world daemon.
3//!
4//! Both send a control request over the daemon socket and report 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 respond` - answer a pending `ask_user` interaction the
38/// daemon is holding, or (with no `request_id`) list the open interactions.
39#[derive(clap::Args, Debug, Clone)]
40pub struct RespondArgs {
41    /// The interaction request id to answer. Omit to list open interactions.
42    pub request_id: Option<String>,
43    /// Free-text (or edited) answer value.
44    pub value: Option<String>,
45    /// Answer a multiple-choice interaction by 0-based option index.
46    #[arg(long)]
47    pub choice: Option<usize>,
48    /// Approve a tool-approval / confirm interaction.
49    #[arg(long, conflicts_with = "deny")]
50    pub approve: bool,
51    /// Deny a tool-approval / confirm interaction.
52    #[arg(long)]
53    pub deny: bool,
54    /// With `--approve`, allow the tool for the rest of the session.
55    #[arg(long)]
56    pub session: bool,
57}
58
59/// Send `request` and report the boolean outcome: `ok` prints `applied_msg`, a
60/// `false` outcome the `not_found_msg`. A non-`Ok` response or a connect failure
61/// is an error.
62async fn send_bool(
63    client: &ControlClient,
64    request: ControlRequest,
65    applied_msg: &str,
66    not_found_msg: &str,
67) -> anyhow::Result<()> {
68    match client.request(&request).await {
69        Ok(ControlResponse::Ok { ok: true }) => {
70            println!("{applied_msg}");
71            Ok(())
72        }
73        Ok(ControlResponse::Ok { ok: false }) => bail!("{not_found_msg}"),
74        Ok(other) => bail!("unexpected daemon response: {other:?}"),
75        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
76    }
77}
78
79/// `lev msg`: deliver a message to a running agent.
80pub async fn send_message(client: &ControlClient, args: &MsgArgs) -> anyhow::Result<()> {
81    send_bool(
82        client,
83        ControlRequest::Message {
84            agent_id: args.agent_id.clone(),
85            content: args.content.clone(),
86            target_region: None,
87        },
88        "message delivered",
89        "no agent accepted the message",
90    )
91    .await
92}
93
94/// `lev cancel`: cancel a run.
95///
96/// A kill must always be possible, so this never depends on the daemon being
97/// reachable. `--force` goes straight to the run's on-disk state; otherwise the
98/// daemon is asked first (it can also stop the work, not just record the
99/// outcome) and the on-disk write is the fallback when it can't be reached or
100/// doesn't answer in time.
101pub async fn cancel_run(client: &ControlClient, args: &CancelArgs) -> anyhow::Result<()> {
102    if args.force {
103        return report_forced(
104            crate::runstate::force_cancel(&args.run_id),
105            &args.run_id,
106            None,
107        );
108    }
109    match client
110        .request(&ControlRequest::Cancel {
111            run_id: args.run_id.clone(),
112        })
113        .await
114    {
115        Ok(ControlResponse::Ok { ok: true }) => {
116            println!("cancelled");
117            Ok(())
118        }
119        Ok(ControlResponse::Ok { ok: false }) => bail!("no such run"),
120        Ok(other) => bail!("unexpected daemon response: {other:?}"),
121        // The daemon is down, wedged, or too busy to answer. Terminate the run on
122        // disk ourselves rather than leave the user with nothing.
123        Err(e) => report_forced(
124            crate::runstate::force_cancel(&args.run_id),
125            &args.run_id,
126            Some(e),
127        ),
128    }
129}
130
131/// Report the outcome of an on-disk cancel. `daemon_error` is set when this was
132/// a fallback rather than an explicit `--force`, and is included so the user
133/// knows why the daemon wasn't used.
134fn report_forced(
135    outcome: crate::runstate::ForceCancelOutcome,
136    run_id: &str,
137    daemon_error: Option<std::io::Error>,
138) -> anyhow::Result<()> {
139    use crate::runstate::ForceCancelOutcome as O;
140    let why = match &daemon_error {
141        Some(e) => format!(" (the daemon did not answer: {e})"),
142        None => String::new(),
143    };
144    match outcome {
145        O::Cancelled => {
146            println!(
147                "cancelled '{run_id}' on disk{why}; if a daemon is still running, \
148                 restart it so it picks up the change"
149            );
150            Ok(())
151        }
152        O::AlreadyTerminal => {
153            println!("'{run_id}' had already finished; nothing to cancel");
154            Ok(())
155        }
156        O::NoSuchRun => match daemon_error {
157            Some(e) => bail!(
158                "the leviath daemon is not reachable ({e}), and there is no run '{run_id}' on disk"
159            ),
160            None => bail!("no such run"),
161        },
162        O::WriteFailed => bail!("could not write '{run_id}' metadata to record the cancel"),
163    }
164}
165
166/// A short human label for an interaction kind (used by the `lev respond` list).
167fn kind_label(kind: &InteractionKind) -> &'static str {
168    match kind {
169        InteractionKind::FreeText => "free-text",
170        InteractionKind::MultipleChoice => "choice",
171        InteractionKind::Confirm => "confirm",
172        InteractionKind::ToolApproval => "tool-approval",
173        InteractionKind::EditText => "edit-text",
174    }
175}
176
177/// Render one open interaction as a multi-line listing entry.
178fn format_interaction(agent_id: &str, req: &InteractionRequest) -> String {
179    let mut s = format!(
180        "{}  [{}]  agent={}  stage={}\n  {}",
181        req.id,
182        kind_label(&req.kind),
183        agent_id,
184        req.stage_name,
185        req.prompt
186    );
187    for (i, opt) in req.options.iter().enumerate() {
188        s.push_str(&format!("\n    {i}) {opt}"));
189    }
190    if let Some(tool) = &req.tool_name {
191        s.push_str(&format!("\n    tool: {tool}"));
192    }
193    s
194}
195
196/// Build the [`InteractionResponse`] implied by the CLI flags. Approve/deny wins,
197/// then an explicit `--choice`, otherwise a free-text value (empty if omitted).
198fn build_response(request_id: &str, args: &RespondArgs) -> InteractionResponse {
199    if args.approve || args.deny {
200        let scope = if args.session {
201            ApprovalScope::Session
202        } else {
203            ApprovalScope::Once
204        };
205        InteractionResponse::approval(request_id, args.approve, scope)
206    } else if let Some(index) = args.choice {
207        InteractionResponse::choice(request_id, index)
208    } else {
209        InteractionResponse::text(request_id, args.value.clone().unwrap_or_default())
210    }
211}
212
213/// List the interactions the daemon is currently holding.
214async fn list_interactions(client: &ControlClient) -> anyhow::Result<()> {
215    match client.request(&ControlRequest::ListInteractions).await {
216        Ok(ControlResponse::Interactions { interactions }) => {
217            if interactions.is_empty() {
218                println!("no open interactions");
219            } else {
220                for (agent_id, req) in &interactions {
221                    println!("{}", format_interaction(agent_id, req));
222                }
223            }
224            Ok(())
225        }
226        Ok(other) => bail!("unexpected daemon response: {other:?}"),
227        Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
228    }
229}
230
231/// `lev respond`: answer a pending interaction, or list open ones when no
232/// `request_id` is given.
233pub async fn respond(client: &ControlClient, args: &RespondArgs) -> anyhow::Result<()> {
234    match &args.request_id {
235        None => list_interactions(client).await,
236        Some(request_id) => {
237            send_bool(
238                client,
239                ControlRequest::AnswerInteraction {
240                    response: build_response(request_id, args),
241                },
242                "answered",
243                "no such open interaction",
244            )
245            .await
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
254    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
255    use tokio::task::JoinHandle;
256
257    /// Bind a control listener at a fresh id under `dir` and serve one canned
258    /// response, returning the id clients connect to and the server task.
259    fn fake_daemon(dir: &std::path::Path, response_line: String) -> (ControlId, JoinHandle<()>) {
260        let id = control_id(dir);
261        let mut listener = bind_control_listener(&id).unwrap();
262        let handle = tokio::spawn(async move {
263            let stream = listener
264                .accept()
265                .await
266                .expect("accept succeeds")
267                .expect("our own connection is admitted");
268            let (read_half, mut write_half) = tokio::io::split(stream);
269            let mut lines = BufReader::new(read_half).lines();
270            let _request = lines.next_line().await.unwrap();
271            write_half
272                .write_all(response_line.as_bytes())
273                .await
274                .unwrap();
275            write_half.write_all(b"\n").await.unwrap();
276        });
277        (id, handle)
278    }
279
280    /// Run `op` against a fake daemon that replies `response_line`.
281    async fn with_daemon<F, Fut>(response_line: impl Into<String>, op: F) -> anyhow::Result<()>
282    where
283        F: FnOnce(ControlClient) -> Fut,
284        Fut: std::future::Future<Output = anyhow::Result<()>>,
285    {
286        let dir = tempfile::tempdir().unwrap();
287        let (id, server) = fake_daemon(dir.path(), response_line.into());
288        let result = op(ControlClient::new(id)).await;
289        server.await.unwrap();
290        result
291    }
292
293    fn msg_args() -> MsgArgs {
294        MsgArgs {
295            agent_id: "a".to_string(),
296            content: "hi".to_string(),
297        }
298    }
299
300    #[tokio::test]
301    async fn message_applied() {
302        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
303            send_message(&c, &msg_args()).await
304        })
305        .await;
306        assert!(r.is_ok());
307    }
308
309    #[tokio::test]
310    async fn message_not_delivered() {
311        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
312            send_message(&c, &msg_args()).await
313        })
314        .await;
315        assert!(r.unwrap_err().to_string().contains("no agent accepted"));
316    }
317
318    #[tokio::test]
319    async fn cancel_applied() {
320        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
321            cancel_run(
322                &c,
323                &CancelArgs {
324                    run_id: "r".to_string(),
325                    force: false,
326                },
327            )
328            .await
329        })
330        .await;
331        assert!(r.is_ok());
332    }
333
334    #[tokio::test]
335    async fn cancel_unknown_run() {
336        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
337            cancel_run(
338                &c,
339                &CancelArgs {
340                    run_id: "r".to_string(),
341                    force: false,
342                },
343            )
344            .await
345        })
346        .await;
347        assert!(r.unwrap_err().to_string().contains("no such run"));
348    }
349
350    #[tokio::test]
351    async fn unexpected_response_is_an_error() {
352        // Both the `send_bool` path (`lev msg`) and `cancel_run`'s own match
353        // reject a response shape they didn't ask for.
354        let r = with_daemon(r#"{"result":"spawned","run_id":"x"}"#, |c| async move {
355            send_message(&c, &msg_args()).await
356        })
357        .await;
358        assert!(r.unwrap_err().to_string().contains("unexpected"));
359
360        let r = with_daemon(r#"{"result":"spawned","run_id":"x"}"#, |c| async move {
361            cancel_run(
362                &c,
363                &CancelArgs {
364                    run_id: "r".to_string(),
365                    force: false,
366                },
367            )
368            .await
369        })
370        .await;
371        assert!(r.unwrap_err().to_string().contains("unexpected"));
372    }
373
374    /// `lev msg` has no on-disk fallback - an unreachable daemon is simply an
375    /// error, unlike `lev cancel`.
376    #[tokio::test]
377    async fn message_to_an_unreachable_daemon_is_an_error() {
378        let dir = tempfile::tempdir().unwrap();
379        let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
380        let err = send_message(&client, &msg_args()).await.unwrap_err();
381        assert!(err.to_string().contains("not reachable"));
382    }
383
384    /// A run directory that cannot be rewritten is reported as such, rather than
385    /// as a successful cancel.
386    #[tokio::test]
387    async fn forcing_a_run_whose_metadata_cannot_be_written_reports_the_failure() {
388        crate::runstate::with_isolated_runs_dir_async("ctl-force-unwritable", |_base| async {
389            let dir = crate::runstate::run_dir("blocked-1");
390            std::fs::create_dir_all(dir.join("meta.json")).unwrap();
391
392            let err = cancel_run(
393                &ControlClient::new(control_id(std::path::Path::new("/nonexistent"))),
394                &CancelArgs {
395                    run_id: "blocked-1".to_string(),
396                    force: true,
397                },
398            )
399            .await
400            .unwrap_err();
401            assert!(err.to_string().contains("could not write"), "got: {err}");
402        })
403        .await;
404    }
405
406    #[tokio::test]
407    async fn not_reachable_is_an_error() {
408        let dir = tempfile::tempdir().unwrap();
409        let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
410        let err = cancel_run(
411            &client,
412            &CancelArgs {
413                run_id: "r".to_string(),
414                force: false,
415            },
416        )
417        .await
418        .unwrap_err();
419        assert!(err.to_string().contains("not reachable"));
420    }
421
422    /// Write a live-looking run into the (isolated) runs dir.
423    fn seed_live_run(run_id: &str) {
424        crate::runstate::create_run(&crate::runstate::RunMeta {
425            status: crate::runstate::RunStatus::Running,
426            ..crate::runstate::RunMeta::new(
427                run_id.into(),
428                "a".into(),
429                "/p".into(),
430                "t".into(),
431                None,
432                "/w".into(),
433                1,
434            )
435        })
436        .unwrap();
437    }
438
439    fn status_of(run_id: &str) -> crate::runstate::RunStatus {
440        crate::runstate::read_meta(run_id).unwrap().status
441    }
442
443    /// `--force` never contacts the daemon, so a kill stays possible when the
444    /// daemon is dead, wedged, or was never started.
445    #[tokio::test]
446    async fn force_cancels_on_disk_without_a_daemon() {
447        crate::runstate::with_isolated_runs_dir_async("ctl-force-cancel", |_base| async {
448            seed_live_run("stuck-1");
449            let dir = tempfile::tempdir().unwrap();
450            // A socket path with nothing listening on it.
451            let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
452
453            cancel_run(
454                &client,
455                &CancelArgs {
456                    run_id: "stuck-1".to_string(),
457                    force: true,
458                },
459            )
460            .await
461            .expect("forced cancel succeeds with no daemon");
462
463            assert_eq!(status_of("stuck-1"), crate::runstate::RunStatus::Cancelled);
464        })
465        .await;
466    }
467
468    /// Without `--force`, an unreachable daemon falls back to the on-disk write
469    /// rather than leaving the user with an error and a run still marked live.
470    #[tokio::test]
471    async fn an_unreachable_daemon_falls_back_to_cancelling_on_disk() {
472        crate::runstate::with_isolated_runs_dir_async("ctl-fallback-cancel", |_base| async {
473            seed_live_run("stuck-2");
474            let dir = tempfile::tempdir().unwrap();
475            let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
476
477            cancel_run(
478                &client,
479                &CancelArgs {
480                    run_id: "stuck-2".to_string(),
481                    force: false,
482                },
483            )
484            .await
485            .expect("the fallback succeeds");
486
487            assert_eq!(status_of("stuck-2"), crate::runstate::RunStatus::Cancelled);
488        })
489        .await;
490    }
491
492    /// Forcing a run that already finished is reported, not treated as a failure.
493    #[tokio::test]
494    async fn forcing_an_already_finished_run_is_not_an_error() {
495        crate::runstate::with_isolated_runs_dir_async("ctl-force-terminal", |_base| async {
496            crate::runstate::create_run(&crate::runstate::RunMeta {
497                status: crate::runstate::RunStatus::Complete,
498                ..crate::runstate::RunMeta::new(
499                    "done-1".into(),
500                    "a".into(),
501                    "/p".into(),
502                    "t".into(),
503                    None,
504                    "/w".into(),
505                    1,
506                )
507            })
508            .unwrap();
509
510            cancel_run(
511                &ControlClient::new(control_id(std::path::Path::new("/nonexistent"))),
512                &CancelArgs {
513                    run_id: "done-1".to_string(),
514                    force: true,
515                },
516            )
517            .await
518            .expect("already-finished is reported, not an error");
519
520            assert_eq!(
521                status_of("done-1"),
522                crate::runstate::RunStatus::Complete,
523                "and the recorded outcome is left intact"
524            );
525        })
526        .await;
527    }
528
529    /// Forcing an id that names no run at all is still an honest failure.
530    #[tokio::test]
531    async fn forcing_an_unknown_run_reports_no_such_run() {
532        crate::runstate::with_isolated_runs_dir_async("ctl-force-missing", |_base| async {
533            let err = cancel_run(
534                &ControlClient::new(control_id(std::path::Path::new("/nonexistent"))),
535                &CancelArgs {
536                    run_id: "never-existed".to_string(),
537                    force: true,
538                },
539            )
540            .await
541            .unwrap_err();
542            assert!(err.to_string().contains("no such run"), "got: {err}");
543        })
544        .await;
545    }
546
547    // ─── lev respond ──────────────────────────────────────────────────────────
548
549    fn respond_args() -> RespondArgs {
550        RespondArgs {
551            request_id: Some("q1".to_string()),
552            value: None,
553            choice: None,
554            approve: false,
555            deny: false,
556            session: false,
557        }
558    }
559
560    #[test]
561    fn build_response_free_text_uses_value_or_empty() {
562        let with_value = build_response(
563            "q1",
564            &RespondArgs {
565                value: Some("hello".to_string()),
566                ..respond_args()
567            },
568        );
569        assert_eq!(with_value, InteractionResponse::text("q1", "hello"));
570        // Missing value → empty string.
571        assert_eq!(
572            build_response("q1", &respond_args()),
573            InteractionResponse::text("q1", "")
574        );
575    }
576
577    #[test]
578    fn build_response_choice_selects_index() {
579        let r = build_response(
580            "q1",
581            &RespondArgs {
582                choice: Some(2),
583                ..respond_args()
584            },
585        );
586        assert_eq!(r, InteractionResponse::choice("q1", 2));
587    }
588
589    #[test]
590    fn build_response_approve_and_deny_and_session_scope() {
591        let approved = build_response(
592            "q1",
593            &RespondArgs {
594                approve: true,
595                ..respond_args()
596            },
597        );
598        assert_eq!(
599            approved,
600            InteractionResponse::approval("q1", true, ApprovalScope::Once)
601        );
602        let session = build_response(
603            "q1",
604            &RespondArgs {
605                approve: true,
606                session: true,
607                ..respond_args()
608            },
609        );
610        assert_eq!(
611            session,
612            InteractionResponse::approval("q1", true, ApprovalScope::Session)
613        );
614        let denied = build_response(
615            "q1",
616            &RespondArgs {
617                deny: true,
618                ..respond_args()
619            },
620        );
621        assert_eq!(
622            denied,
623            InteractionResponse::approval("q1", false, ApprovalScope::Once)
624        );
625    }
626
627    #[test]
628    fn kind_label_covers_every_kind() {
629        for (kind, label) in [
630            (InteractionKind::FreeText, "free-text"),
631            (InteractionKind::MultipleChoice, "choice"),
632            (InteractionKind::Confirm, "confirm"),
633            (InteractionKind::ToolApproval, "tool-approval"),
634            (InteractionKind::EditText, "edit-text"),
635        ] {
636            assert_eq!(kind_label(&kind), label);
637        }
638    }
639
640    #[test]
641    fn format_interaction_renders_options_and_tool() {
642        let mut req = InteractionRequest::multiple_choice(
643            "q1",
644            "Pick",
645            vec!["a".to_string(), "b".to_string()],
646            "plan",
647        );
648        req.tool_name = Some("bash".to_string());
649        let out = format_interaction("agent-x", &req);
650        assert!(out.contains("q1  [choice]  agent=agent-x  stage=plan"));
651        assert!(out.contains("Pick"));
652        assert!(out.contains("0) a"));
653        assert!(out.contains("1) b"));
654        assert!(out.contains("tool: bash"));
655    }
656
657    #[tokio::test]
658    async fn respond_answers_an_interaction() {
659        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
660            respond(&c, &respond_args()).await
661        })
662        .await;
663        assert!(r.is_ok());
664    }
665
666    #[tokio::test]
667    async fn respond_reports_no_open_interaction() {
668        let r = with_daemon(r#"{"result":"ok","ok":false}"#, |c| async move {
669            respond(&c, &respond_args()).await
670        })
671        .await;
672        assert!(r.unwrap_err().to_string().contains("no such open"));
673    }
674
675    #[tokio::test]
676    async fn respond_lists_open_interactions() {
677        let req = InteractionRequest::free_text("q1", "What now?", "plan", true);
678        let line = serde_json::to_string(&ControlResponse::Interactions {
679            interactions: vec![("agent-a".to_string(), req)],
680        })
681        .unwrap();
682        let r = with_daemon(line, |c| async move {
683            respond(
684                &c,
685                &RespondArgs {
686                    request_id: None,
687                    ..respond_args()
688                },
689            )
690            .await
691        })
692        .await;
693        assert!(r.is_ok());
694    }
695
696    #[tokio::test]
697    async fn respond_lists_when_none_open() {
698        let line = serde_json::to_string(&ControlResponse::Interactions {
699            interactions: vec![],
700        })
701        .unwrap();
702        let r = with_daemon(line, |c| async move {
703            respond(
704                &c,
705                &RespondArgs {
706                    request_id: None,
707                    ..respond_args()
708                },
709            )
710            .await
711        })
712        .await;
713        assert!(r.is_ok());
714    }
715
716    #[tokio::test]
717    async fn respond_list_rejects_unexpected_response() {
718        let r = with_daemon(r#"{"result":"ok","ok":true}"#, |c| async move {
719            respond(
720                &c,
721                &RespondArgs {
722                    request_id: None,
723                    ..respond_args()
724                },
725            )
726            .await
727        })
728        .await;
729        assert!(r.unwrap_err().to_string().contains("unexpected"));
730    }
731
732    #[tokio::test]
733    async fn respond_list_errors_when_daemon_absent() {
734        let dir = tempfile::tempdir().unwrap();
735        let client = ControlClient::new(control_id(&dir.path().join("no-daemon")));
736        let err = respond(
737            &client,
738            &RespondArgs {
739                request_id: None,
740                ..respond_args()
741            },
742        )
743        .await
744        .unwrap_err();
745        assert!(err.to_string().contains("not reachable"));
746    }
747}