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, visible_alias = "run")]
70 pub session: bool,
71 #[arg(long, conflicts_with = "session")]
74 pub stage: bool,
75 #[arg(long)]
78 pub json: bool,
79}
80
81#[derive(serde::Serialize)]
87struct OpenInteraction<'a> {
88 agent_id: &'a str,
90 #[serde(flatten)]
91 request: &'a InteractionRequest,
92}
93
94async 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
114pub 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
129pub 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
143pub 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
156pub 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 Err(e) => report_forced(
186 crate::runstate::force_cancel(&args.run_id),
187 &args.run_id,
188 Some(e),
189 ),
190 }
191}
192
193fn 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
228fn 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
239fn 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
258fn 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
275async 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 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
306pub 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 let applied = match args.json {
315 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 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 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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 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 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 #[test]
838 fn open_interaction_serializes_the_agent_id_alongside_the_request() {
839 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}