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)]
39pub struct PauseArgs {
40 pub run_id: String,
42}
43
44#[derive(clap::Args, Debug, Clone)]
46pub struct ResumeArgs {
47 pub run_id: String,
49}
50
51#[derive(clap::Args, Debug, Clone)]
54pub struct RespondArgs {
55 pub request_id: Option<String>,
57 pub value: Option<String>,
59 #[arg(long)]
61 pub choice: Option<usize>,
62 #[arg(long, conflicts_with = "deny")]
64 pub approve: bool,
65 #[arg(long)]
67 pub deny: bool,
68 #[arg(long)]
70 pub session: bool,
71 #[arg(long)]
74 pub json: bool,
75}
76
77#[derive(serde::Serialize)]
83struct OpenInteraction<'a> {
84 agent_id: &'a str,
86 #[serde(flatten)]
87 request: &'a InteractionRequest,
88}
89
90async 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
110pub 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
125pub 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
139pub 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
152pub 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 Err(e) => report_forced(
182 crate::runstate::force_cancel(&args.run_id),
183 &args.run_id,
184 Some(e),
185 ),
186 }
187}
188
189fn 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
224fn 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
235fn 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
254fn 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
271async 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 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
302pub 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 let applied = match args.json {
311 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 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 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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[test]
821 fn open_interaction_serializes_the_agent_id_alongside_the_request() {
822 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}