1use anyhow::bail;
9use leviath_core::interaction::{
10 ApprovalScope, InteractionKind, InteractionRequest, InteractionResponse,
11};
12use leviath_runtime::control_socket::{ControlClient, ControlRequest, ControlResponse};
13
14#[derive(clap::Args, Debug, Clone)]
16pub struct MsgArgs {
17 pub agent_id: String,
19 pub content: String,
21}
22
23#[derive(clap::Args, Debug, Clone)]
25pub struct CancelArgs {
26 pub run_id: String,
28 #[arg(long)]
34 pub force: bool,
35}
36
37#[derive(clap::Args, Debug, Clone)]
40pub struct RespondArgs {
41 pub request_id: Option<String>,
43 pub value: Option<String>,
45 #[arg(long)]
47 pub choice: Option<usize>,
48 #[arg(long, conflicts_with = "deny")]
50 pub approve: bool,
51 #[arg(long)]
53 pub deny: bool,
54 #[arg(long)]
56 pub session: bool,
57}
58
59async 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
79pub 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
94pub 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 Err(e) => report_forced(
124 crate::runstate::force_cancel(&args.run_id),
125 &args.run_id,
126 Some(e),
127 ),
128 }
129}
130
131fn 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
166fn 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
177fn 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
196fn 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
213async 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
231pub 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 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 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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 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}