1use std::collections::HashMap;
192use std::future::Future;
193use std::pin::Pin;
194use std::process::Stdio;
195use std::sync::Arc;
196use std::time::Duration;
197
198use serde_json::Value;
199use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
200use tokio::process::{Child, ChildStdin, ChildStdout, Command};
201use tokio::sync::{broadcast, mpsc, oneshot, watch};
202use tokio::task::JoinHandle;
203use tracing::{Instrument, debug, warn};
204
205use crate::Claude;
206use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
207use crate::error::{Error, Result};
208use crate::tool_pattern::ToolPattern;
209use crate::types::{Effort, HermeticScope, PermissionMode};
210
211pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
216
217#[derive(Debug, Clone)]
226pub struct PermissionRequest {
227 pub request_id: String,
230 pub tool_name: String,
232 pub input: Value,
234 pub raw: Value,
237}
238
239#[derive(Debug, Clone)]
248pub enum PermissionDecision {
249 Allow {
251 updated_input: Option<Value>,
254 },
255 Deny {
257 message: String,
259 },
260 Defer,
263}
264
265type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
266type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
267
268#[derive(Clone)]
281pub struct PermissionHandler {
282 inner: Arc<PermissionFn>,
283}
284
285impl PermissionHandler {
286 pub fn new<F, Fut>(f: F) -> Self
302 where
303 F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
304 Fut: Future<Output = PermissionDecision> + Send + 'static,
305 {
306 Self {
307 inner: Arc::new(move |req| Box::pin(f(req))),
308 }
309 }
310
311 fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
312 (self.inner)(req)
313 }
314}
315
316impl std::fmt::Debug for PermissionHandler {
317 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 f.debug_struct("PermissionHandler").finish_non_exhaustive()
319 }
320}
321
322#[derive(Debug, Default, Clone)]
350pub struct DuplexOptions {
351 shared: SharedSpawnArgs,
354 additional_args: Vec<String>,
355 subscriber_capacity: Option<usize>,
356 on_permission: Option<PermissionHandler>,
357}
358
359impl DuplexOptions {
360 #[must_use]
362 pub fn model(mut self, model: impl Into<String>) -> Self {
363 self.shared.model = Some(model.into());
364 self
365 }
366
367 #[must_use]
369 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
370 self.shared.system_prompt = Some(prompt.into());
371 self
372 }
373
374 #[must_use]
376 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
377 self.shared.append_system_prompt = Some(prompt.into());
378 self
379 }
380
381 #[must_use]
398 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
399 self.shared.resume = Some(session_id.into());
400 self
401 }
402
403 #[must_use]
410 pub fn continue_session(mut self) -> Self {
411 self.shared.continue_session = true;
412 self
413 }
414
415 #[must_use]
426 pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
427 self.shared.worktree = true;
428 if let Some(n) = name {
429 self.shared.worktree_name = Some(n.into());
430 }
431 self
432 }
433
434 #[must_use]
448 pub fn agent(mut self, name: impl Into<String>) -> Self {
449 self.shared.agent = Some(name.into());
450 self
451 }
452
453 #[must_use]
465 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
466 self.shared.agents_json = Some(json.into());
467 self
468 }
469
470 #[must_use]
486 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
487 self.shared.permission_mode = Some(mode);
488 self
489 }
490
491 #[must_use]
499 pub fn dangerously_skip_permissions(mut self) -> Self {
500 self.shared.dangerously_skip_permissions = true;
501 self
502 }
503
504 #[must_use]
514 pub fn session_id(mut self, id: impl Into<String>) -> Self {
515 self.shared.session_id = Some(id.into());
516 self
517 }
518
519 #[must_use]
527 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
528 self.shared.json_schema = Some(schema.into());
529 self
530 }
531
532 #[must_use]
540 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
541 where
542 I: IntoIterator<Item = T>,
543 T: Into<ToolPattern>,
544 {
545 self.shared
546 .allowed_tools
547 .extend(tools.into_iter().map(Into::into));
548 self
549 }
550
551 #[must_use]
553 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
554 self.shared.allowed_tools.push(tool.into());
555 self
556 }
557
558 #[must_use]
560 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
561 where
562 I: IntoIterator<Item = T>,
563 T: Into<ToolPattern>,
564 {
565 self.shared
566 .disallowed_tools
567 .extend(tools.into_iter().map(Into::into));
568 self
569 }
570
571 #[must_use]
573 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
574 self.shared.disallowed_tools.push(tool.into());
575 self
576 }
577
578 #[must_use]
584 pub fn max_turns(mut self, turns: u32) -> Self {
585 self.shared.max_turns = Some(turns);
586 self
587 }
588
589 #[must_use]
599 pub fn max_budget_usd(mut self, budget: f64) -> Self {
600 self.shared.max_budget_usd = Some(budget);
601 self
602 }
603
604 #[must_use]
607 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
608 self.shared.fallback_model = Some(model.into());
609 self
610 }
611
612 #[must_use]
614 pub fn effort(mut self, effort: Effort) -> Self {
615 self.shared.effort = Some(effort);
616 self
617 }
618
619 #[must_use]
622 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
623 self.shared.add_dir.push(dir.into());
624 self
625 }
626
627 #[must_use]
632 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
633 self.shared.mcp_config.push(path.into());
634 self
635 }
636
637 #[must_use]
641 pub fn strict_mcp_config(mut self) -> Self {
642 self.shared.strict_mcp_config = true;
643 self
644 }
645
646 #[must_use]
652 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
653 self.shared.setting_sources = Some(sources.into());
654 self
655 }
656
657 #[must_use]
670 pub fn hermetic(mut self) -> Self {
671 self.shared.apply_hermetic(HermeticScope::Full);
672 self
673 }
674
675 #[must_use]
681 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
682 self.shared.apply_hermetic(scope);
683 self
684 }
685
686 #[must_use]
689 pub fn no_session_persistence(mut self) -> Self {
690 self.shared.no_session_persistence = true;
691 self
692 }
693
694 #[must_use]
702 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
703 self.shared.tools.extend(tools.into_iter().map(Into::into));
704 self
705 }
706
707 #[must_use]
712 pub fn file(mut self, spec: impl Into<String>) -> Self {
713 self.shared.file.push(spec.into());
714 self
715 }
716
717 #[must_use]
721 pub fn settings(mut self, settings: impl Into<String>) -> Self {
722 self.shared.settings = Some(settings.into());
723 self
724 }
725
726 #[must_use]
733 pub fn fork_session(mut self) -> Self {
734 self.shared.fork_session = true;
735 self
736 }
737
738 #[must_use]
742 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
743 self.shared.debug_filter = Some(filter.into());
744 self
745 }
746
747 #[must_use]
751 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
752 self.shared.debug_file = Some(path.into());
753 self
754 }
755
756 #[must_use]
760 pub fn betas(mut self, betas: impl Into<String>) -> Self {
761 self.shared.betas = Some(betas.into());
762 self
763 }
764
765 #[must_use]
769 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
770 self.shared.plugin_dirs.push(dir.into());
771 self
772 }
773
774 #[must_use]
778 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
779 self.shared.plugin_urls.push(url.into());
780 self
781 }
782
783 #[must_use]
787 pub fn tmux(mut self) -> Self {
788 self.shared.tmux = true;
789 self
790 }
791
792 #[must_use]
800 pub fn bare(mut self) -> Self {
801 self.shared.bare = true;
802 self
803 }
804
805 #[must_use]
812 pub fn safe_mode(mut self) -> Self {
813 self.shared.safe_mode = true;
814 self
815 }
816
817 #[must_use]
822 pub fn disable_slash_commands(mut self) -> Self {
823 self.shared.disable_slash_commands = true;
824 self
825 }
826
827 #[must_use]
834 pub fn include_hook_events(mut self) -> Self {
835 self.shared.include_hook_events = true;
836 self
837 }
838
839 #[must_use]
848 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
849 self.shared.exclude_dynamic_system_prompt_sections = true;
850 self
851 }
852
853 #[must_use]
857 pub fn name(mut self, name: impl Into<String>) -> Self {
858 self.shared.name = Some(name.into());
859 self
860 }
861
862 #[must_use]
867 pub fn arg(mut self, arg: impl Into<String>) -> Self {
868 self.additional_args.push(arg.into());
869 self
870 }
871
872 #[must_use]
880 pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
881 self.subscriber_capacity = Some(capacity);
882 self
883 }
884
885 #[must_use]
903 pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
904 self.on_permission = Some(handler);
905 self
906 }
907
908 fn build_args(&self) -> Vec<String> {
909 let mut args = vec![
910 "--print".to_string(),
911 "--verbose".to_string(),
912 "--output-format".to_string(),
913 "stream-json".to_string(),
914 "--input-format".to_string(),
915 "stream-json".to_string(),
916 ];
917
918 self.shared.append_to(&mut args);
919
920 if self.on_permission.is_some() {
921 args.push("--permission-prompt-tool".to_string());
922 args.push("stdio".to_string());
923 }
924 args.extend(self.additional_args.iter().cloned());
925
926 args
927 }
928
929 fn spawn_command_args(&self, claude: &Claude) -> Vec<String> {
935 let mut args = claude.global_args.clone();
936 args.extend(self.build_args());
937 args
938 }
939
940 #[must_use]
969 pub fn to_command_string(&self, claude: &Claude) -> String {
970 let args = self.spawn_command_args(claude);
971 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
972 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
973 }
974}
975
976#[derive(Debug, Clone)]
983pub struct TurnResult {
984 pub result: Value,
986 pub events: Vec<Value>,
988}
989
990impl TurnResult {
991 #[must_use]
993 pub fn result_text(&self) -> Option<&str> {
994 self.result.get("result").and_then(Value::as_str)
995 }
996
997 #[must_use]
999 pub fn session_id(&self) -> Option<&str> {
1000 self.result.get("session_id").and_then(Value::as_str)
1001 }
1002
1003 #[must_use]
1010 pub fn total_cost_usd(&self) -> Option<f64> {
1011 self.result
1012 .get("total_cost_usd")
1013 .or_else(|| self.result.get("cost_usd"))
1014 .and_then(Value::as_f64)
1015 }
1016
1017 #[must_use]
1019 pub fn duration_ms(&self) -> Option<u64> {
1020 self.result.get("duration_ms").and_then(Value::as_u64)
1021 }
1022}
1023
1024#[derive(Debug, Clone)]
1038pub enum InboundEvent {
1039 SystemInit {
1042 session_id: String,
1045 },
1046 Assistant(Value),
1049 StreamEvent(Value),
1052 User(Value),
1055 Other(Value),
1058}
1059
1060fn classify(msg: &Value) -> InboundEvent {
1061 match msg.get("type").and_then(Value::as_str) {
1062 Some("system") => {
1063 if msg.get("subtype").and_then(Value::as_str) == Some("init")
1064 && let Some(id) = msg.get("session_id").and_then(Value::as_str)
1065 {
1066 return InboundEvent::SystemInit {
1067 session_id: id.to_string(),
1068 };
1069 }
1070 InboundEvent::Other(msg.clone())
1071 }
1072 Some("assistant") => InboundEvent::Assistant(msg.clone()),
1073 Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
1074 Some("user") => InboundEvent::User(msg.clone()),
1075 _ => InboundEvent::Other(msg.clone()),
1076 }
1077}
1078
1079#[derive(Debug, Clone)]
1094pub enum SessionExitStatus {
1095 Running,
1097 Completed,
1100 Failed(String),
1103}
1104
1105#[derive(Debug)]
1114pub struct DuplexSession {
1115 outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
1116 events_tx: broadcast::Sender<InboundEvent>,
1117 exit_rx: watch::Receiver<SessionExitStatus>,
1118 join: JoinHandle<Result<()>>,
1119}
1120
1121#[derive(Debug)]
1122enum OutboundMsg {
1123 Send {
1124 prompt: String,
1125 reply: oneshot::Sender<Result<TurnResult>>,
1126 },
1127 PermissionResponse {
1128 request_id: String,
1129 decision: PermissionDecision,
1130 },
1131 Interrupt {
1132 reply: oneshot::Sender<Result<()>>,
1133 },
1134}
1135
1136impl DuplexSession {
1137 pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
1145 let capacity = opts
1146 .subscriber_capacity
1147 .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
1148 let permission_handler = opts.on_permission.clone();
1149
1150 let command_args = opts.spawn_command_args(claude);
1151
1152 debug!(
1153 binary = %claude.binary.display(),
1154 args = ?command_args,
1155 "spawning duplex claude session"
1156 );
1157
1158 let mut cmd = Command::new(&claude.binary);
1159 cmd.args(&command_args)
1160 .env_remove("CLAUDECODE")
1161 .env_remove("CLAUDE_CODE_ENTRYPOINT")
1162 .envs(&claude.env)
1163 .stdin(Stdio::piped())
1164 .stdout(Stdio::piped())
1165 .stderr(Stdio::piped())
1166 .kill_on_drop(true);
1167 crate::exec::apply_process_group(&mut cmd, claude.process_group);
1171
1172 if let Some(ref dir) = claude.working_dir {
1173 cmd.current_dir(dir);
1174 }
1175
1176 let mut child = cmd.spawn().map_err(|e| Error::Io {
1177 message: format!("failed to spawn claude: {e}"),
1178 source: e,
1179 working_dir: claude.working_dir.clone(),
1180 })?;
1181 let group =
1182 crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
1183
1184 let stdin = child.stdin.take().expect("stdin was piped");
1185 let stdout = child.stdout.take().expect("stdout was piped");
1186
1187 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1188 let (events_tx, _initial_rx) = broadcast::channel(capacity);
1189 let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1190
1191 let session_span = tracing::debug_span!(
1200 "claude.session",
1201 session_id = tracing::field::Empty,
1202 model = opts.shared.model.as_deref().unwrap_or("default"),
1203 permission_mode = opts
1204 .shared
1205 .permission_mode
1206 .as_ref()
1207 .map(|m| m.as_arg())
1208 .unwrap_or("default"),
1209 resumed = opts.shared.resume.is_some(),
1210 turns = tracing::field::Empty,
1211 exit = tracing::field::Empty,
1212 );
1213 let join = tokio::spawn(
1214 run_session(
1215 child,
1216 group,
1217 claude.kill_grace,
1218 stdin,
1219 stdout,
1220 outbound_rx,
1221 events_tx.clone(),
1222 permission_handler,
1223 exit_tx,
1224 )
1225 .instrument(session_span),
1226 );
1227
1228 Ok(Self {
1229 outbound_tx,
1230 events_tx,
1231 exit_rx,
1232 join,
1233 })
1234 }
1235
1236 pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1247 let (reply_tx, reply_rx) = oneshot::channel();
1248 self.outbound_tx
1249 .send(OutboundMsg::Send {
1250 prompt: prompt.into(),
1251 reply: reply_tx,
1252 })
1253 .map_err(|_| Error::DuplexClosed)?;
1254 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1255 }
1256
1257 #[must_use]
1292 pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1293 self.events_tx.subscribe()
1294 }
1295
1296 #[must_use]
1307 pub fn is_alive(&self) -> bool {
1308 matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1309 }
1310
1311 #[must_use]
1320 pub fn exit_status(&self) -> SessionExitStatus {
1321 self.exit_rx.borrow().clone()
1322 }
1323
1324 pub async fn wait_for_exit(&self) -> SessionExitStatus {
1336 let mut rx = self.exit_rx.clone();
1337 loop {
1338 {
1339 let value = rx.borrow_and_update();
1340 if !matches!(*value, SessionExitStatus::Running) {
1341 return value.clone();
1342 }
1343 }
1344 if rx.changed().await.is_err() {
1345 return rx.borrow().clone();
1346 }
1347 }
1348 }
1349
1350 pub fn respond_to_permission(
1395 &self,
1396 request_id: impl Into<String>,
1397 decision: PermissionDecision,
1398 ) -> Result<()> {
1399 if matches!(decision, PermissionDecision::Defer) {
1400 warn!("respond_to_permission called with Defer; ignoring");
1401 return Ok(());
1402 }
1403 self.outbound_tx
1404 .send(OutboundMsg::PermissionResponse {
1405 request_id: request_id.into(),
1406 decision,
1407 })
1408 .map_err(|_| Error::DuplexClosed)?;
1409 Ok(())
1410 }
1411
1412 pub async fn interrupt(&self) -> Result<()> {
1453 let (reply_tx, reply_rx) = oneshot::channel();
1454 self.outbound_tx
1455 .send(OutboundMsg::Interrupt { reply: reply_tx })
1456 .map_err(|_| Error::DuplexClosed)?;
1457 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1458 }
1459
1460 pub async fn close(self) -> Result<()> {
1466 drop(self.outbound_tx);
1467 drop(self.events_tx);
1468 match self.join.await {
1469 Ok(result) => result,
1470 Err(e) if e.is_cancelled() => Ok(()),
1471 Err(e) => Err(Error::Io {
1472 message: format!("duplex session task panicked: {e}"),
1473 source: std::io::Error::other(e.to_string()),
1474 working_dir: None,
1475 }),
1476 }
1477 }
1478}
1479
1480const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1484
1485#[allow(clippy::too_many_arguments)]
1486async fn run_session(
1487 mut child: Child,
1488 mut group: crate::exec::GroupKillGuard,
1489 kill_grace: Option<Duration>,
1490 mut stdin: ChildStdin,
1491 stdout: ChildStdout,
1492 mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1493 events_tx: broadcast::Sender<InboundEvent>,
1494 permission_handler: Option<PermissionHandler>,
1495 exit_tx: watch::Sender<SessionExitStatus>,
1496) -> Result<()> {
1497 let mut lines = BufReader::new(stdout).lines();
1498 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1499 let mut pending_control: HashMap<String, oneshot::Sender<Result<()>>> = HashMap::new();
1500 let mut next_control_id: u64 = 0;
1501 let mut stream_err: Option<Error> = None;
1502 let session_span = tracing::Span::current();
1504 let mut turns: u64 = 0;
1505 let mut turn_span: Option<tracing::Span> = None;
1506 let mut turn_started: Option<std::time::Instant> = None;
1507
1508 loop {
1509 tokio::select! {
1510 biased;
1511
1512 line = lines.next_line() => match line {
1513 Ok(Some(l)) => {
1514 if l.trim().is_empty() {
1515 continue;
1516 }
1517 let parsed = match serde_json::from_str::<Value>(&l) {
1518 Ok(v) => v,
1519 Err(e) => {
1520 debug!(line = %l, error = %e, "failed to parse duplex event, skipping");
1521 continue;
1522 }
1523 };
1524 match parsed.get("type").and_then(Value::as_str) {
1528 Some("system")
1529 if parsed.get("subtype").and_then(Value::as_str) == Some("init") =>
1530 {
1531 if let Some(id) = parsed.get("session_id").and_then(Value::as_str) {
1532 session_span.record("session_id", id);
1533 }
1534 }
1535 Some("result") => {
1536 if let Some(span) = turn_span.take() {
1537 span.record(
1538 "is_error",
1539 parsed.get("is_error").and_then(Value::as_bool).unwrap_or(false),
1540 );
1541 if let Some(sub) = parsed.get("subtype").and_then(Value::as_str) {
1542 span.record("subtype", sub);
1543 }
1544 if let Some(c) =
1545 parsed.get("total_cost_usd").and_then(Value::as_f64)
1546 {
1547 span.record("cost_usd", c);
1548 }
1549 if let Some(started) = turn_started.take() {
1550 span.record(
1551 "duration_ms",
1552 started.elapsed().as_millis() as u64,
1553 );
1554 }
1555 }
1556 }
1557 _ => {}
1558 }
1559 match handle_inbound(parsed, &mut pending, &events_tx) {
1560 InboundAction::None => {}
1561 InboundAction::Permission(req) => {
1562 let request_id = req.request_id.clone();
1563 let decision = match permission_handler.as_ref() {
1564 Some(h) => h.invoke(req).await,
1565 None => {
1566 warn!(
1567 request_id = %request_id,
1568 "received can_use_tool with no permission handler; auto-denying"
1569 );
1570 PermissionDecision::Deny {
1571 message:
1572 "no permission handler configured on duplex session"
1573 .into(),
1574 }
1575 }
1576 };
1577 if matches!(decision, PermissionDecision::Defer) {
1578 debug!(
1579 request_id = %request_id,
1580 "permission handler deferred; waiting for respond_to_permission"
1581 );
1582 } else if let Err(e) =
1583 write_permission_response(&mut stdin, &request_id, &decision).await
1584 {
1585 warn!(error = %e, "failed to write permission response");
1586 }
1587 }
1588 InboundAction::ControlResponse { request_id, outcome } => {
1589 if let Some(reply) = pending_control.remove(&request_id) {
1590 let _ = reply.send(outcome);
1591 } else {
1592 debug!(
1593 request_id = %request_id,
1594 "received control_response with no pending request"
1595 );
1596 }
1597 }
1598 }
1599 }
1600 Ok(None) => break,
1601 Err(e) => {
1602 stream_err = Some(Error::Io {
1603 message: "failed to read duplex stdout".to_string(),
1604 source: e,
1605 working_dir: None,
1606 });
1607 break;
1608 }
1609 },
1610
1611 msg = outbound_rx.recv() => match msg {
1612 Some(OutboundMsg::Send { prompt, reply }) => {
1613 if pending.is_some() {
1614 let _ = reply.send(Err(Error::DuplexTurnInFlight));
1615 continue;
1616 }
1617 if let Err(e) = write_user(&mut stdin, &prompt).await {
1618 let _ = reply.send(Err(e));
1619 continue;
1620 }
1621 turns += 1;
1622 turn_span = Some(tracing::debug_span!(
1627 parent: &session_span,
1628 "claude.turn",
1629 turn = turns,
1630 is_error = tracing::field::Empty,
1631 subtype = tracing::field::Empty,
1632 cost_usd = tracing::field::Empty,
1633 duration_ms = tracing::field::Empty,
1634 ));
1635 turn_started = Some(std::time::Instant::now());
1636 pending = Some((reply, Vec::new()));
1637 }
1638 Some(OutboundMsg::PermissionResponse { request_id, decision }) => {
1639 if let Err(e) =
1640 write_permission_response(&mut stdin, &request_id, &decision).await
1641 {
1642 warn!(error = %e, "failed to write deferred permission response");
1643 }
1644 }
1645 Some(OutboundMsg::Interrupt { reply }) => {
1646 next_control_id += 1;
1647 let request_id = format!("interrupt-{next_control_id}");
1648 if let Err(e) =
1649 write_control_request(&mut stdin, &request_id, "interrupt").await
1650 {
1651 let _ = reply.send(Err(e));
1652 continue;
1653 }
1654 pending_control.insert(request_id, reply);
1655 }
1656 None => break,
1657 },
1658 }
1659 }
1660
1661 drop(stdin);
1662 match tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()).await {
1663 Ok(Ok(_status)) => {
1664 group.disarm();
1665 }
1666 Ok(Err(e)) => {
1667 warn!(error = %e, "failed to wait for duplex child");
1668 }
1669 Err(_) => {
1670 warn!("duplex child did not exit within shutdown budget; killing");
1671 crate::exec::kill_group_with_grace(&mut group, kill_grace).await;
1675 let _ = child.kill().await;
1676 }
1677 }
1678
1679 if let Some((reply, _)) = pending.take() {
1680 let _ = reply.send(Err(Error::DuplexClosed));
1681 }
1682 for (_, reply) in pending_control.drain() {
1683 let _ = reply.send(Err(Error::DuplexClosed));
1684 }
1685
1686 let result = match stream_err {
1687 Some(e) => Err(e),
1688 None => Ok(()),
1689 };
1690 let final_state = match &result {
1691 Ok(()) => SessionExitStatus::Completed,
1692 Err(e) => SessionExitStatus::Failed(e.to_string()),
1693 };
1694 session_span.record("turns", turns);
1695 session_span.record(
1696 "exit",
1697 match &final_state {
1698 SessionExitStatus::Completed => "completed",
1699 SessionExitStatus::Failed(_) => "failed",
1700 SessionExitStatus::Running => "running",
1701 },
1702 );
1703 let _ = exit_tx.send(final_state);
1704 result
1705}
1706
1707enum InboundAction {
1711 None,
1713 Permission(PermissionRequest),
1717 ControlResponse {
1722 request_id: String,
1723 outcome: Result<()>,
1724 },
1725}
1726
1727fn handle_inbound(
1728 msg: Value,
1729 pending: &mut Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>,
1730 events_tx: &broadcast::Sender<InboundEvent>,
1731) -> InboundAction {
1732 match msg.get("type").and_then(Value::as_str) {
1733 Some("result") => {
1734 if let Some((reply, events)) = pending.take() {
1735 let _ = reply.send(Ok(TurnResult {
1736 result: msg,
1737 events,
1738 }));
1739 } else {
1740 debug!("dropping orphan result event with no pending turn");
1741 }
1742 InboundAction::None
1743 }
1744 Some("control_request") => {
1745 if msg
1748 .get("request")
1749 .and_then(|r| r.get("subtype"))
1750 .and_then(Value::as_str)
1751 == Some("can_use_tool")
1752 && let Some(req) = parse_permission_request(&msg)
1753 {
1754 if let Some((_, events)) = pending.as_mut() {
1755 events.push(msg);
1756 }
1757 return InboundAction::Permission(req);
1758 }
1759 debug!(
1760 ?msg,
1761 "received unhandled control_request; treating as Other"
1762 );
1763 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1764 if let Some((_, events)) = pending.as_mut() {
1765 events.push(msg);
1766 }
1767 InboundAction::None
1768 }
1769 Some("control_response") => {
1770 if let Some((request_id, outcome)) = parse_control_response(&msg) {
1771 return InboundAction::ControlResponse {
1772 request_id,
1773 outcome,
1774 };
1775 }
1776 debug!(
1777 ?msg,
1778 "received malformed control_response; treating as Other"
1779 );
1780 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1781 if let Some((_, events)) = pending.as_mut() {
1782 events.push(msg);
1783 }
1784 InboundAction::None
1785 }
1786 _ => {
1787 let _ = events_tx.send(classify(&msg));
1790
1791 if let Some((_, events)) = pending.as_mut() {
1792 events.push(msg);
1793 } else {
1794 debug!("dropping inbound event with no pending turn");
1795 }
1796 InboundAction::None
1797 }
1798 }
1799}
1800
1801fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
1802 let request_id = msg.get("request_id").and_then(Value::as_str)?;
1803 let request = msg.get("request")?;
1804 let tool_name = request.get("tool_name").and_then(Value::as_str)?;
1805 let input = request.get("input").cloned().unwrap_or(Value::Null);
1806 Some(PermissionRequest {
1807 request_id: request_id.to_string(),
1808 tool_name: tool_name.to_string(),
1809 input,
1810 raw: request.clone(),
1811 })
1812}
1813
1814fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
1820 let response = msg.get("response")?;
1821 let request_id = response.get("request_id").and_then(Value::as_str)?;
1822 let outcome = match response.get("subtype").and_then(Value::as_str) {
1823 Some("success") => Ok(()),
1824 Some("error") => {
1825 let message = response
1826 .get("error")
1827 .and_then(Value::as_str)
1828 .unwrap_or("unknown control_response error")
1829 .to_string();
1830 Err(Error::DuplexControlFailed { message })
1831 }
1832 _ => return None,
1833 };
1834 Some((request_id.to_string(), outcome))
1835}
1836
1837async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
1838 let user_msg = serde_json::json!({
1839 "type": "user",
1840 "message": {
1841 "role": "user",
1842 "content": prompt,
1843 },
1844 "parent_tool_use_id": null,
1845 });
1846 write_line(stdin, &user_msg, "user message").await
1847}
1848
1849async fn write_control_request(
1850 stdin: &mut ChildStdin,
1851 request_id: &str,
1852 subtype: &str,
1853) -> Result<()> {
1854 let envelope = serde_json::json!({
1855 "type": "control_request",
1856 "request_id": request_id,
1857 "request": { "subtype": subtype },
1858 });
1859 write_line(stdin, &envelope, "control_request").await
1860}
1861
1862async fn write_permission_response(
1863 stdin: &mut ChildStdin,
1864 request_id: &str,
1865 decision: &PermissionDecision,
1866) -> Result<()> {
1867 let inner = match decision {
1868 PermissionDecision::Allow { updated_input } => {
1869 let mut obj = serde_json::Map::new();
1870 obj.insert("behavior".to_string(), Value::String("allow".to_string()));
1871 if let Some(input) = updated_input {
1872 obj.insert("updatedInput".to_string(), input.clone());
1873 }
1874 Value::Object(obj)
1875 }
1876 PermissionDecision::Deny { message } => serde_json::json!({
1877 "behavior": "deny",
1878 "message": message,
1879 }),
1880 PermissionDecision::Defer => {
1881 return Ok(());
1883 }
1884 };
1885 let envelope = serde_json::json!({
1886 "type": "control_response",
1887 "response": {
1888 "request_id": request_id,
1889 "subtype": "success",
1890 "response": inner,
1891 },
1892 });
1893 write_line(stdin, &envelope, "control_response").await
1894}
1895
1896async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
1897 let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
1898 message: format!("failed to serialize duplex {what}"),
1899 source: e,
1900 })?;
1901 line.push('\n');
1902 stdin
1903 .write_all(line.as_bytes())
1904 .await
1905 .map_err(|e| Error::Io {
1906 message: format!("failed to write {what} to duplex stdin"),
1907 source: e,
1908 working_dir: None,
1909 })?;
1910 stdin.flush().await.map_err(|e| Error::Io {
1911 message: "failed to flush duplex stdin".to_string(),
1912 source: e,
1913 working_dir: None,
1914 })?;
1915 Ok(())
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920 use super::*;
1921 use serde_json::json;
1922
1923 #[test]
1924 fn build_args_default_includes_required_flags() {
1925 let args = DuplexOptions::default().build_args();
1926 assert!(args.contains(&"--print".to_string()));
1927 assert!(args.contains(&"--verbose".to_string()));
1928 assert!(
1929 args.windows(2)
1930 .any(|w| w == ["--output-format", "stream-json"])
1931 );
1932 assert!(
1933 args.windows(2)
1934 .any(|w| w == ["--input-format", "stream-json"])
1935 );
1936 }
1937
1938 #[test]
1939 fn build_args_includes_model() {
1940 let args = DuplexOptions::default().model("haiku").build_args();
1941 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1942 }
1943
1944 #[test]
1945 fn build_args_includes_system_prompts() {
1946 let args = DuplexOptions::default()
1947 .system_prompt("be concise")
1948 .append_system_prompt("also polite")
1949 .build_args();
1950 assert!(
1951 args.windows(2)
1952 .any(|w| w == ["--system-prompt", "be concise"])
1953 );
1954 assert!(
1955 args.windows(2)
1956 .any(|w| w == ["--append-system-prompt", "also polite"])
1957 );
1958 }
1959
1960 #[test]
1961 fn build_args_appends_raw_args_last() {
1962 let args = DuplexOptions::default()
1963 .arg("--add-dir")
1964 .arg("/tmp/foo")
1965 .build_args();
1966 assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
1968 }
1969
1970 fn preview_claude() -> Claude {
1973 Claude::builder()
1974 .binary("/usr/local/bin/claude")
1975 .build()
1976 .unwrap()
1977 }
1978
1979 #[test]
1980 fn spawn_command_args_prepends_global_args() {
1981 let claude = Claude::builder()
1982 .binary("/usr/local/bin/claude")
1983 .arg("--debug")
1984 .build()
1985 .unwrap();
1986 let args = DuplexOptions::default()
1987 .model("haiku")
1988 .spawn_command_args(&claude);
1989 assert_eq!(args[0], "--debug");
1990 assert_eq!(args[1], "--print");
1991 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1992 }
1993
1994 #[test]
1995 fn to_command_string_is_binary_plus_spawn_args() {
1996 let claude = Claude::builder()
1999 .binary("/usr/local/bin/claude")
2000 .arg("--debug")
2001 .build()
2002 .unwrap();
2003 let opts = DuplexOptions::default().agent("reviewer");
2004 let expected = format!(
2005 "/usr/local/bin/claude {}",
2006 opts.spawn_command_args(&claude).join(" ")
2007 );
2008 assert_eq!(opts.to_command_string(&claude), expected);
2009 }
2010
2011 #[test]
2012 fn to_command_string_includes_persona_flags() {
2013 let command_str = DuplexOptions::default()
2016 .agent("reviewer")
2017 .allowed_tool("Read")
2018 .allowed_tool("Bash(git:*)")
2019 .setting_sources("project")
2020 .to_command_string(&preview_claude());
2021 assert!(command_str.starts_with("/usr/local/bin/claude"));
2022 assert!(command_str.contains("--agent reviewer"));
2023 assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
2024 assert!(command_str.contains("--setting-sources project"));
2025 }
2026
2027 #[test]
2028 fn to_command_string_quotes_args_with_spaces() {
2029 let command_str = DuplexOptions::default()
2030 .system_prompt("be concise")
2031 .to_command_string(&preview_claude());
2032 assert!(command_str.contains("--system-prompt 'be concise'"));
2033 }
2034
2035 #[test]
2036 fn to_command_string_does_not_consume_options() {
2037 let claude = preview_claude();
2040 let opts = DuplexOptions::default().model("haiku");
2041 let first = opts.to_command_string(&claude);
2042 let second = opts.to_command_string(&claude);
2043 assert_eq!(first, second);
2044 }
2045
2046 #[test]
2047 fn build_args_includes_resume_when_set() {
2048 let args = DuplexOptions::default().resume("abc-123").build_args();
2049 assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
2050 }
2051
2052 #[test]
2053 fn build_args_omits_resume_by_default() {
2054 let args = DuplexOptions::default().build_args();
2055 assert!(
2056 !args.iter().any(|a| a == "--resume"),
2057 "--resume should not appear without an explicit resume(...) call; got {args:?}"
2058 );
2059 }
2060
2061 #[test]
2062 fn build_args_includes_continue_when_set() {
2063 let args = DuplexOptions::default().continue_session().build_args();
2064 assert!(args.iter().any(|a| a == "--continue"));
2065 }
2066
2067 #[test]
2068 fn build_args_omits_continue_by_default() {
2069 let args = DuplexOptions::default().build_args();
2070 assert!(!args.iter().any(|a| a == "--continue"));
2071 }
2072
2073 #[test]
2074 fn build_args_includes_worktree_flag_without_name() {
2075 let args = DuplexOptions::default().worktree(None::<&str>).build_args();
2076 assert!(args.iter().any(|a| a == "--worktree"));
2077 let pos = args.iter().position(|a| a == "--worktree").unwrap();
2079 assert!(
2080 args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
2081 "--worktree without a name should not be followed by a positional; got {args:?}"
2082 );
2083 }
2084
2085 #[test]
2086 fn build_args_includes_worktree_flag_with_name() {
2087 let args = DuplexOptions::default()
2088 .worktree(Some("agent-xyz"))
2089 .build_args();
2090 let pos = args.iter().position(|a| a == "--worktree").unwrap();
2091 assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
2092 }
2093
2094 #[test]
2095 fn build_args_omits_worktree_by_default() {
2096 let args = DuplexOptions::default().build_args();
2097 assert!(
2098 !args.iter().any(|a| a == "--worktree"),
2099 "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn worktree_lands_before_additional_args() {
2105 let args = DuplexOptions::default()
2107 .worktree(Some("foo"))
2108 .arg("--")
2109 .arg("trailing")
2110 .build_args();
2111 let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
2112 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2113 assert!(
2114 wt_pos < dash_dash_pos,
2115 "--worktree must precede `--` separator; got {args:?}"
2116 );
2117 }
2118
2119 #[test]
2120 fn build_args_includes_agent_when_set() {
2121 let args = DuplexOptions::default().agent("rust-qa").build_args();
2122 assert!(
2123 args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
2124 "missing --agent rust-qa in {args:?}"
2125 );
2126 }
2127
2128 #[test]
2129 fn build_args_omits_agent_by_default() {
2130 let args = DuplexOptions::default().build_args();
2131 assert!(
2132 !args.iter().any(|a| a == "--agent"),
2133 "--agent should not appear without an explicit agent(...) call; got {args:?}"
2134 );
2135 }
2136
2137 #[test]
2138 fn build_args_includes_agents_json_when_set() {
2139 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2140 let args = DuplexOptions::default().agents_json(json).build_args();
2141 let pos = args.iter().position(|a| a == "--agents").unwrap();
2142 assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
2143 }
2144
2145 #[test]
2146 fn build_args_omits_agents_json_by_default() {
2147 let args = DuplexOptions::default().build_args();
2148 assert!(!args.iter().any(|a| a == "--agents"));
2149 }
2150
2151 #[test]
2152 fn agent_and_agents_json_compose() {
2153 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2154 let args = DuplexOptions::default()
2155 .agents_json(json)
2156 .agent("reviewer")
2157 .build_args();
2158 assert!(args.iter().any(|a| a == "--agents"));
2160 assert!(args.iter().any(|a| a == "--agent"));
2161 }
2162
2163 #[test]
2164 fn agent_lands_before_additional_args() {
2165 let args = DuplexOptions::default()
2166 .agent("rust-qa")
2167 .arg("--")
2168 .arg("trailing")
2169 .build_args();
2170 let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
2171 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2172 assert!(
2173 agent_pos < dash_dash_pos,
2174 "--agent must precede `--` separator; got {args:?}"
2175 );
2176 }
2177
2178 #[test]
2179 fn agents_json_lands_before_additional_args() {
2180 let args = DuplexOptions::default()
2181 .agents_json("{}")
2182 .arg("--")
2183 .arg("trailing")
2184 .build_args();
2185 let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
2186 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2187 assert!(
2188 agents_pos < dash_dash_pos,
2189 "--agents must precede `--` separator; got {args:?}"
2190 );
2191 }
2192
2193 #[test]
2196 fn build_args_includes_session_id() {
2197 let args = DuplexOptions::default().session_id("sid-9").build_args();
2198 assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
2199 }
2200
2201 #[test]
2202 fn build_args_includes_setting_sources() {
2203 let args = DuplexOptions::default()
2204 .setting_sources("user,project")
2205 .build_args();
2206 assert!(
2207 args.windows(2)
2208 .any(|w| w == ["--setting-sources", "user,project"]),
2209 "got {args:?}"
2210 );
2211 }
2212
2213 #[test]
2214 fn build_args_omits_setting_sources_by_default() {
2215 let args = DuplexOptions::default().build_args();
2216 assert!(!args.iter().any(|a| a == "--setting-sources"));
2217 }
2218
2219 #[test]
2220 fn build_args_hermetic_emits_full_seal() {
2221 let args = DuplexOptions::default().hermetic().build_args();
2222 assert!(
2223 args.windows(2)
2224 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
2225 "got {args:?}"
2226 );
2227 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2228 assert!(
2229 args.iter()
2230 .any(|a| a == "--exclude-dynamic-system-prompt-sections")
2231 );
2232 assert!(!args.iter().any(|a| a == "--bare"));
2234 }
2235
2236 #[test]
2237 fn build_args_hermetic_scoped_project_keeps_user() {
2238 let args = DuplexOptions::default()
2239 .hermetic_scoped(HermeticScope::Project)
2240 .build_args();
2241 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
2242 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2243 }
2244
2245 #[test]
2246 fn build_args_includes_json_schema() {
2247 let schema = r#"{"type":"object"}"#;
2248 let args = DuplexOptions::default().json_schema(schema).build_args();
2249 assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
2250 }
2251
2252 #[test]
2253 fn build_args_joins_allowed_tools_comma_separated() {
2254 let args = DuplexOptions::default()
2255 .allowed_tools(["Read", "Bash(git log:*)"])
2256 .allowed_tool("Write")
2257 .build_args();
2258 assert!(
2259 args.windows(2)
2260 .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
2261 "missing joined --allowed-tools in {args:?}"
2262 );
2263 }
2264
2265 #[test]
2266 fn build_args_joins_disallowed_tools_comma_separated() {
2267 let args = DuplexOptions::default()
2268 .disallowed_tools(["WebSearch"])
2269 .disallowed_tool("WebFetch")
2270 .build_args();
2271 assert!(
2272 args.windows(2)
2273 .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
2274 "missing joined --disallowed-tools in {args:?}"
2275 );
2276 }
2277
2278 #[test]
2279 fn build_args_includes_caps() {
2280 let args = DuplexOptions::default()
2281 .max_turns(4)
2282 .max_budget_usd(0.25)
2283 .build_args();
2284 assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
2285 assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
2286 }
2287
2288 #[test]
2289 fn build_args_includes_fallback_model_and_effort() {
2290 let args = DuplexOptions::default()
2291 .fallback_model("haiku")
2292 .effort(Effort::Low)
2293 .build_args();
2294 assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
2295 assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
2296 }
2297
2298 #[test]
2299 fn build_args_repeats_add_dir_and_mcp_config() {
2300 let args = DuplexOptions::default()
2301 .add_dir("/a")
2302 .add_dir("/b")
2303 .mcp_config("x.json")
2304 .strict_mcp_config()
2305 .build_args();
2306 assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
2307 assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
2308 assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
2309 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2310 }
2311
2312 #[test]
2313 fn build_args_includes_no_session_persistence() {
2314 let args = DuplexOptions::default()
2315 .no_session_persistence()
2316 .build_args();
2317 assert!(args.iter().any(|a| a == "--no-session-persistence"));
2318 }
2319
2320 #[test]
2323 fn build_args_joins_tools_comma_separated() {
2324 let args = DuplexOptions::default()
2325 .tools(["Bash", "Read", "Edit"])
2326 .build_args();
2327 assert!(
2328 args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
2329 "missing joined --tools in {args:?}"
2330 );
2331 }
2332
2333 #[test]
2334 fn build_args_repeats_file_per_spec() {
2335 let args = DuplexOptions::default()
2336 .file("file_a:doc.txt")
2337 .file("file_b:notes.md")
2338 .build_args();
2339 assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
2340 assert!(args.iter().any(|a| a == "file_a:doc.txt"));
2341 assert!(args.iter().any(|a| a == "file_b:notes.md"));
2342 }
2343
2344 #[test]
2345 fn build_args_includes_settings() {
2346 let args = DuplexOptions::default()
2347 .settings("/tmp/settings.json")
2348 .build_args();
2349 assert!(
2350 args.windows(2)
2351 .any(|w| w == ["--settings", "/tmp/settings.json"])
2352 );
2353 }
2354
2355 #[test]
2356 fn build_args_includes_fork_session() {
2357 let args = DuplexOptions::default().fork_session().build_args();
2358 assert!(args.iter().any(|a| a == "--fork-session"));
2359 }
2360
2361 #[test]
2362 fn build_args_includes_debug_filter_and_file() {
2363 let args = DuplexOptions::default()
2364 .debug_filter("api,hooks")
2365 .debug_file("/tmp/debug.log")
2366 .build_args();
2367 assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
2368 assert!(
2369 args.windows(2)
2370 .any(|w| w == ["--debug-file", "/tmp/debug.log"])
2371 );
2372 }
2373
2374 #[test]
2375 fn build_args_includes_betas() {
2376 let args = DuplexOptions::default().betas("feature-x").build_args();
2377 assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
2378 }
2379
2380 #[test]
2381 fn build_args_repeats_plugin_dir_and_url() {
2382 let args = DuplexOptions::default()
2383 .plugin_dir("/plugins/a")
2384 .plugin_dir("/plugins/b")
2385 .plugin_url("https://example.com/p.zip")
2386 .build_args();
2387 assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
2388 assert!(
2389 args.windows(2)
2390 .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
2391 );
2392 }
2393
2394 #[test]
2395 fn build_args_includes_bare_family_bool_flags() {
2396 let args = DuplexOptions::default()
2397 .tmux()
2398 .bare()
2399 .safe_mode()
2400 .disable_slash_commands()
2401 .include_hook_events()
2402 .exclude_dynamic_system_prompt_sections()
2403 .build_args();
2404 for flag in [
2405 "--tmux",
2406 "--bare",
2407 "--safe-mode",
2408 "--disable-slash-commands",
2409 "--include-hook-events",
2410 "--exclude-dynamic-system-prompt-sections",
2411 ] {
2412 assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
2413 }
2414 }
2415
2416 #[test]
2417 fn build_args_includes_name() {
2418 let args = DuplexOptions::default().name("my session").build_args();
2419 assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
2420 }
2421
2422 #[test]
2423 fn build_args_omits_promoted_parity_flags_by_default() {
2424 let args = DuplexOptions::default().build_args();
2425 for flag in [
2426 "--tools",
2427 "--file",
2428 "--settings",
2429 "--fork-session",
2430 "--debug",
2431 "--debug-file",
2432 "--betas",
2433 "--plugin-dir",
2434 "--plugin-url",
2435 "--tmux",
2436 "--bare",
2437 "--safe-mode",
2438 "--disable-slash-commands",
2439 "--include-hook-events",
2440 "--exclude-dynamic-system-prompt-sections",
2441 "--name",
2442 ] {
2443 assert!(
2444 !args.iter().any(|a| a == flag),
2445 "{flag} should be absent by default; got {args:?}"
2446 );
2447 }
2448 }
2449
2450 #[test]
2451 fn parity_flags_land_before_additional_args() {
2452 let args = DuplexOptions::default()
2454 .max_turns(2)
2455 .json_schema("{}")
2456 .arg("--")
2457 .arg("trailing")
2458 .build_args();
2459 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2460 for flag in ["--max-turns", "--json-schema"] {
2461 let pos = args.iter().position(|a| a == flag).unwrap();
2462 assert!(
2463 pos < dash_dash_pos,
2464 "{flag} must precede `--` separator; got {args:?}"
2465 );
2466 }
2467 }
2468
2469 #[test]
2470 fn build_args_omits_parity_flags_by_default() {
2471 let args = DuplexOptions::default().build_args();
2472 for flag in [
2473 "--session-id",
2474 "--json-schema",
2475 "--allowed-tools",
2476 "--disallowed-tools",
2477 "--max-turns",
2478 "--max-budget-usd",
2479 "--fallback-model",
2480 "--effort",
2481 "--add-dir",
2482 "--mcp-config",
2483 "--strict-mcp-config",
2484 "--no-session-persistence",
2485 ] {
2486 assert!(
2487 !args.iter().any(|a| a == flag),
2488 "{flag} should not appear by default; got {args:?}"
2489 );
2490 }
2491 }
2492
2493 #[test]
2494 fn resume_lands_before_additional_args() {
2495 let args = DuplexOptions::default()
2500 .resume("xyz")
2501 .arg("--")
2502 .arg("trailing")
2503 .build_args();
2504 let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
2505 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2506 assert!(
2507 resume_pos < dash_dash_pos,
2508 "--resume must precede `--` separator; got {args:?}"
2509 );
2510 }
2511
2512 #[test]
2513 fn turn_result_accessors_pull_from_result() {
2514 let r = TurnResult {
2515 result: json!({
2516 "type": "result",
2517 "result": "hello",
2518 "session_id": "sess-123",
2519 "total_cost_usd": 0.0042,
2520 "duration_ms": 1234_u64,
2521 }),
2522 events: vec![],
2523 };
2524 assert_eq!(r.result_text(), Some("hello"));
2525 assert_eq!(r.session_id(), Some("sess-123"));
2526 assert_eq!(r.total_cost_usd(), Some(0.0042));
2527 assert_eq!(r.duration_ms(), Some(1234));
2528 }
2529
2530 #[test]
2531 fn turn_result_total_cost_falls_back_to_legacy_field() {
2532 let r = TurnResult {
2533 result: json!({ "cost_usd": 0.5 }),
2534 events: vec![],
2535 };
2536 assert_eq!(r.total_cost_usd(), Some(0.5));
2537 }
2538
2539 #[test]
2540 fn turn_result_accessors_return_none_when_missing() {
2541 let r = TurnResult {
2542 result: json!({}),
2543 events: vec![],
2544 };
2545 assert_eq!(r.result_text(), None);
2546 assert_eq!(r.session_id(), None);
2547 assert_eq!(r.total_cost_usd(), None);
2548 assert_eq!(r.duration_ms(), None);
2549 }
2550
2551 #[test]
2552 fn handle_inbound_appends_non_result_to_pending_events() {
2553 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2554 let (events_tx, _events_rx) = broadcast::channel(16);
2555 let mut pending = Some((tx, Vec::new()));
2556 handle_inbound(
2557 json!({ "type": "assistant", "message": {} }),
2558 &mut pending,
2559 &events_tx,
2560 );
2561 let (_, events) = pending.as_ref().unwrap();
2562 assert_eq!(events.len(), 1);
2563 assert_eq!(
2564 events[0].get("type").and_then(Value::as_str),
2565 Some("assistant")
2566 );
2567 }
2568
2569 #[test]
2570 fn handle_inbound_resolves_pending_on_result() {
2571 let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
2572 let (events_tx, _events_rx) = broadcast::channel(16);
2573 let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
2574 handle_inbound(
2575 json!({ "type": "result", "result": "ok" }),
2576 &mut pending,
2577 &events_tx,
2578 );
2579 assert!(pending.is_none());
2580 let received = rx.blocking_recv().unwrap().unwrap();
2581 assert_eq!(received.result_text(), Some("ok"));
2582 assert_eq!(received.events.len(), 1);
2583 }
2584
2585 #[test]
2586 fn handle_inbound_drops_orphans_without_pending_turn() {
2587 let (events_tx, _events_rx) = broadcast::channel(16);
2588 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
2589 handle_inbound(json!({ "type": "assistant" }), &mut pending, &events_tx);
2590 handle_inbound(
2591 json!({ "type": "result", "result": "ok" }),
2592 &mut pending,
2593 &events_tx,
2594 );
2595 assert!(pending.is_none());
2596 }
2597
2598 #[test]
2599 fn handle_inbound_broadcasts_classified_event() {
2600 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2601 let (events_tx, mut events_rx) = broadcast::channel(16);
2602 let mut pending = Some((tx, Vec::new()));
2603 handle_inbound(
2604 json!({ "type": "assistant", "message": { "role": "assistant" } }),
2605 &mut pending,
2606 &events_tx,
2607 );
2608 let event = events_rx.try_recv().expect("classified event broadcast");
2609 assert!(matches!(event, InboundEvent::Assistant(_)));
2610 }
2611
2612 #[test]
2613 fn handle_inbound_does_not_broadcast_result() {
2614 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2615 let (events_tx, mut events_rx) = broadcast::channel(16);
2616 let mut pending = Some((tx, Vec::new()));
2617 handle_inbound(
2618 json!({ "type": "result", "result": "ok" }),
2619 &mut pending,
2620 &events_tx,
2621 );
2622 assert!(events_rx.try_recv().is_err());
2624 }
2625
2626 #[test]
2627 fn classify_system_init_pulls_session_id() {
2628 let v = json!({
2629 "type": "system",
2630 "subtype": "init",
2631 "session_id": "sess-abc",
2632 });
2633 match classify(&v) {
2634 InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
2635 other => panic!("expected SystemInit, got {other:?}"),
2636 }
2637 }
2638
2639 #[test]
2640 fn classify_system_without_init_subtype_is_other() {
2641 let v = json!({ "type": "system", "subtype": "compaction" });
2642 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2643 }
2644
2645 #[test]
2646 fn classify_system_init_without_session_id_is_other() {
2647 let v = json!({ "type": "system", "subtype": "init" });
2648 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2649 }
2650
2651 #[test]
2652 fn classify_assistant_stream_event_user() {
2653 assert!(matches!(
2654 classify(&json!({ "type": "assistant" })),
2655 InboundEvent::Assistant(_)
2656 ));
2657 assert!(matches!(
2658 classify(&json!({ "type": "stream_event" })),
2659 InboundEvent::StreamEvent(_)
2660 ));
2661 assert!(matches!(
2662 classify(&json!({ "type": "user" })),
2663 InboundEvent::User(_)
2664 ));
2665 }
2666
2667 #[test]
2668 fn classify_unknown_type_is_other() {
2669 assert!(matches!(
2670 classify(&json!({ "type": "control_request" })),
2671 InboundEvent::Other(_)
2672 ));
2673 assert!(matches!(
2674 classify(&json!({ "type": "future_thing" })),
2675 InboundEvent::Other(_)
2676 ));
2677 assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
2678 }
2679
2680 #[test]
2681 fn build_args_does_not_emit_subscriber_capacity_flag() {
2682 let args = DuplexOptions::default()
2684 .subscriber_capacity(64)
2685 .build_args();
2686 assert!(!args.iter().any(|a| a.contains("subscriber")));
2687 assert!(!args.iter().any(|a| a.contains("capacity")));
2688 }
2689
2690 #[test]
2691 fn build_args_includes_permission_prompt_tool_when_handler_set() {
2692 let handler = PermissionHandler::new(|_req| async move {
2693 PermissionDecision::Allow {
2694 updated_input: None,
2695 }
2696 });
2697 let args = DuplexOptions::default().on_permission(handler).build_args();
2698 assert!(
2699 args.windows(2)
2700 .any(|w| w == ["--permission-prompt-tool", "stdio"])
2701 );
2702 }
2703
2704 #[test]
2705 fn build_args_omits_permission_prompt_tool_without_handler() {
2706 let args = DuplexOptions::default().build_args();
2707 assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
2708 }
2709
2710 #[test]
2711 fn build_args_emits_permission_mode_flag() {
2712 let args = DuplexOptions::default()
2713 .permission_mode(PermissionMode::AcceptEdits)
2714 .build_args();
2715 assert!(
2716 args.windows(2)
2717 .any(|w| w == ["--permission-mode", "acceptEdits"]),
2718 "missing --permission-mode acceptEdits in {args:?}"
2719 );
2720 }
2721
2722 #[test]
2723 fn build_args_emits_plan_mode() {
2724 let args = DuplexOptions::default()
2725 .permission_mode(PermissionMode::Plan)
2726 .build_args();
2727 assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
2728 }
2729
2730 #[test]
2731 fn build_args_omits_permission_mode_by_default() {
2732 let args = DuplexOptions::default().build_args();
2733 assert!(!args.iter().any(|a| a == "--permission-mode"));
2734 }
2735
2736 #[test]
2737 fn build_args_emits_dangerously_skip_permissions_flag() {
2738 let args = DuplexOptions::default()
2739 .dangerously_skip_permissions()
2740 .build_args();
2741 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
2742 }
2743
2744 #[test]
2745 fn build_args_omits_dangerously_skip_by_default() {
2746 let args = DuplexOptions::default().build_args();
2747 assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
2748 }
2749
2750 #[test]
2751 fn parse_permission_request_extracts_fields() {
2752 let msg = json!({
2753 "type": "control_request",
2754 "request_id": "req-1",
2755 "request": {
2756 "subtype": "can_use_tool",
2757 "tool_name": "Bash",
2758 "input": { "command": "ls" }
2759 }
2760 });
2761 let req = parse_permission_request(&msg).expect("permission request");
2762 assert_eq!(req.request_id, "req-1");
2763 assert_eq!(req.tool_name, "Bash");
2764 assert_eq!(req.input, json!({ "command": "ls" }));
2765 assert_eq!(
2766 req.raw.get("subtype").and_then(Value::as_str),
2767 Some("can_use_tool")
2768 );
2769 }
2770
2771 #[test]
2772 fn parse_permission_request_returns_none_when_missing_request_id() {
2773 let msg = json!({
2774 "type": "control_request",
2775 "request": {
2776 "subtype": "can_use_tool",
2777 "tool_name": "Bash",
2778 }
2779 });
2780 assert!(parse_permission_request(&msg).is_none());
2781 }
2782
2783 #[test]
2784 fn parse_permission_request_returns_none_when_missing_tool_name() {
2785 let msg = json!({
2786 "type": "control_request",
2787 "request_id": "req-1",
2788 "request": { "subtype": "can_use_tool" }
2789 });
2790 assert!(parse_permission_request(&msg).is_none());
2791 }
2792
2793 #[test]
2794 fn parse_permission_request_handles_missing_input() {
2795 let msg = json!({
2796 "type": "control_request",
2797 "request_id": "req-1",
2798 "request": {
2799 "subtype": "can_use_tool",
2800 "tool_name": "Bash",
2801 }
2802 });
2803 let req = parse_permission_request(&msg).expect("request");
2804 assert_eq!(req.input, Value::Null);
2805 }
2806
2807 #[test]
2808 fn handle_inbound_returns_permission_for_can_use_tool() {
2809 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2810 let (events_tx, _events_rx) = broadcast::channel(16);
2811 let mut pending = Some((tx, Vec::new()));
2812 let action = handle_inbound(
2813 json!({
2814 "type": "control_request",
2815 "request_id": "req-1",
2816 "request": {
2817 "subtype": "can_use_tool",
2818 "tool_name": "Bash",
2819 "input": { "command": "ls" }
2820 }
2821 }),
2822 &mut pending,
2823 &events_tx,
2824 );
2825 match action {
2826 InboundAction::Permission(req) => {
2827 assert_eq!(req.request_id, "req-1");
2828 assert_eq!(req.tool_name, "Bash");
2829 }
2830 InboundAction::None | InboundAction::ControlResponse { .. } => {
2831 panic!("expected Permission action");
2832 }
2833 }
2834 let (_, events) = pending.as_ref().unwrap();
2836 assert_eq!(events.len(), 1);
2837 }
2838
2839 #[test]
2840 fn handle_inbound_treats_unknown_control_request_as_other() {
2841 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2842 let (events_tx, mut events_rx) = broadcast::channel(16);
2843 let mut pending = Some((tx, Vec::new()));
2844 let action = handle_inbound(
2845 json!({
2846 "type": "control_request",
2847 "request_id": "req-2",
2848 "request": { "subtype": "future_subtype" }
2849 }),
2850 &mut pending,
2851 &events_tx,
2852 );
2853 assert!(matches!(action, InboundAction::None));
2854 let event = events_rx.try_recv().expect("broadcast");
2855 assert!(matches!(event, InboundEvent::Other(_)));
2856 }
2857
2858 #[tokio::test]
2859 async fn permission_handler_invokes_closure_async() {
2860 let handler = PermissionHandler::new(|req| async move {
2861 if req.tool_name == "Bash" {
2862 PermissionDecision::Deny {
2863 message: "no bash".into(),
2864 }
2865 } else {
2866 PermissionDecision::Allow {
2867 updated_input: None,
2868 }
2869 }
2870 });
2871 let req = PermissionRequest {
2872 request_id: "r1".into(),
2873 tool_name: "Bash".into(),
2874 input: Value::Null,
2875 raw: Value::Null,
2876 };
2877 match handler.invoke(req).await {
2878 PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
2879 other => panic!("expected Deny, got {other:?}"),
2880 }
2881 }
2882
2883 #[test]
2884 fn parse_control_response_extracts_success() {
2885 let msg = json!({
2886 "type": "control_response",
2887 "response": {
2888 "request_id": "interrupt-1",
2889 "subtype": "success",
2890 "response": {}
2891 }
2892 });
2893 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2894 assert_eq!(id, "interrupt-1");
2895 assert!(outcome.is_ok());
2896 }
2897
2898 #[test]
2899 fn parse_control_response_extracts_error_with_message() {
2900 let msg = json!({
2901 "type": "control_response",
2902 "response": {
2903 "request_id": "interrupt-2",
2904 "subtype": "error",
2905 "error": "no turn in flight"
2906 }
2907 });
2908 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2909 assert_eq!(id, "interrupt-2");
2910 match outcome {
2911 Err(Error::DuplexControlFailed { message }) => {
2912 assert_eq!(message, "no turn in flight");
2913 }
2914 other => panic!("expected DuplexControlFailed, got {other:?}"),
2915 }
2916 }
2917
2918 #[test]
2919 fn parse_control_response_returns_none_on_missing_request_id() {
2920 let msg = json!({
2921 "type": "control_response",
2922 "response": { "subtype": "success" }
2923 });
2924 assert!(parse_control_response(&msg).is_none());
2925 }
2926
2927 #[test]
2928 fn parse_control_response_returns_none_on_unknown_subtype() {
2929 let msg = json!({
2930 "type": "control_response",
2931 "response": { "request_id": "x", "subtype": "future_subtype" }
2932 });
2933 assert!(parse_control_response(&msg).is_none());
2934 }
2935
2936 #[test]
2937 fn handle_inbound_returns_control_response_action() {
2938 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2939 let (events_tx, _events_rx) = broadcast::channel(16);
2940 let mut pending = Some((tx, Vec::new()));
2941 let action = handle_inbound(
2942 json!({
2943 "type": "control_response",
2944 "response": {
2945 "request_id": "interrupt-1",
2946 "subtype": "success",
2947 "response": {}
2948 }
2949 }),
2950 &mut pending,
2951 &events_tx,
2952 );
2953 match action {
2954 InboundAction::ControlResponse {
2955 request_id,
2956 outcome,
2957 } => {
2958 assert_eq!(request_id, "interrupt-1");
2959 assert!(outcome.is_ok());
2960 }
2961 InboundAction::None | InboundAction::Permission(_) => {
2962 panic!("expected ControlResponse action");
2963 }
2964 }
2965 }
2966
2967 #[test]
2968 fn handle_inbound_treats_malformed_control_response_as_other() {
2969 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2970 let (events_tx, mut events_rx) = broadcast::channel(16);
2971 let mut pending = Some((tx, Vec::new()));
2972 let action = handle_inbound(
2973 json!({
2974 "type": "control_response",
2975 "response": { "subtype": "success" }
2976 }),
2977 &mut pending,
2978 &events_tx,
2979 );
2980 assert!(matches!(action, InboundAction::None));
2981 let event = events_rx.try_recv().expect("broadcast");
2982 assert!(matches!(event, InboundEvent::Other(_)));
2983 }
2984
2985 #[tokio::test]
2986 async fn permission_handler_clones_arc() {
2987 let handler = PermissionHandler::new(|_req| async move {
2988 PermissionDecision::Allow {
2989 updated_input: None,
2990 }
2991 });
2992 let cloned = handler.clone();
2993 let req = PermissionRequest {
2994 request_id: "r1".into(),
2995 tool_name: "Read".into(),
2996 input: Value::Null,
2997 raw: Value::Null,
2998 };
2999 let _ = handler.invoke(req.clone()).await;
3001 let _ = cloned.invoke(req).await;
3002 }
3003
3004 fn fake_session(
3011 initial: SessionExitStatus,
3012 ) -> (
3013 DuplexSession,
3014 watch::Sender<SessionExitStatus>,
3015 oneshot::Sender<()>,
3016 ) {
3017 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
3018 let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
3019 let (exit_tx, exit_rx) = watch::channel(initial);
3020 let (stop_tx, stop_rx) = oneshot::channel::<()>();
3021
3022 let join = tokio::spawn(async move {
3023 let _outbound_rx = outbound_rx;
3024 let _ = stop_rx.await;
3025 Ok::<(), Error>(())
3026 });
3027
3028 let session = DuplexSession {
3029 outbound_tx,
3030 events_tx,
3031 exit_rx,
3032 join,
3033 };
3034 (session, exit_tx, stop_tx)
3035 }
3036
3037 #[tokio::test]
3038 async fn is_alive_true_while_running() {
3039 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3040 assert!(session.is_alive());
3041 }
3042
3043 #[tokio::test]
3044 async fn is_alive_false_after_completed() {
3045 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3046 exit_tx.send(SessionExitStatus::Completed).unwrap();
3047 assert!(!session.is_alive());
3048 }
3049
3050 #[tokio::test]
3051 async fn is_alive_false_after_failed() {
3052 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3053 exit_tx
3054 .send(SessionExitStatus::Failed("boom".into()))
3055 .unwrap();
3056 assert!(!session.is_alive());
3057 }
3058
3059 #[tokio::test]
3060 async fn exit_status_reports_running_initially() {
3061 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3062 assert!(matches!(session.exit_status(), SessionExitStatus::Running));
3063 }
3064
3065 #[tokio::test]
3066 async fn exit_status_reflects_completed() {
3067 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3068 exit_tx.send(SessionExitStatus::Completed).unwrap();
3069 assert!(matches!(
3070 session.exit_status(),
3071 SessionExitStatus::Completed
3072 ));
3073 }
3074
3075 #[tokio::test]
3076 async fn exit_status_reflects_failed_with_message() {
3077 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3078 exit_tx
3079 .send(SessionExitStatus::Failed("oh no".into()))
3080 .unwrap();
3081 match session.exit_status() {
3082 SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
3083 other => panic!("expected Failed, got {other:?}"),
3084 }
3085 }
3086
3087 #[tokio::test]
3088 async fn wait_for_exit_returns_immediately_when_already_terminal() {
3089 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3090 exit_tx.send(SessionExitStatus::Completed).unwrap();
3091 let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
3092 .await
3093 .expect("wait_for_exit should not block when already terminal");
3094 assert!(matches!(status, SessionExitStatus::Completed));
3095 }
3096
3097 #[tokio::test]
3098 async fn wait_for_exit_blocks_until_state_transitions() {
3099 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3100
3101 let waiter = async { session.wait_for_exit().await };
3102 let driver = async {
3103 tokio::time::sleep(Duration::from_millis(20)).await;
3104 exit_tx.send(SessionExitStatus::Completed).unwrap();
3105 };
3106 let (status, ()) = tokio::join!(waiter, driver);
3107 assert!(matches!(status, SessionExitStatus::Completed));
3108 }
3109
3110 #[tokio::test]
3111 async fn wait_for_exit_supports_multiple_observers() {
3112 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3113
3114 let waiter1 = async { session.wait_for_exit().await };
3115 let waiter2 = async { session.wait_for_exit().await };
3116 let driver = async {
3117 tokio::time::sleep(Duration::from_millis(20)).await;
3118 exit_tx
3119 .send(SessionExitStatus::Failed("crash".into()))
3120 .unwrap();
3121 };
3122 let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
3123 match s1 {
3124 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3125 other => panic!("waiter1 expected Failed, got {other:?}"),
3126 }
3127 match s2 {
3128 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3129 other => panic!("waiter2 expected Failed, got {other:?}"),
3130 }
3131 }
3132
3133 #[tokio::test]
3134 async fn wait_for_exit_returns_last_value_when_sender_dropped() {
3135 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3139 let waiter = async { session.wait_for_exit().await };
3140 let driver = async {
3141 tokio::time::sleep(Duration::from_millis(20)).await;
3142 drop(exit_tx);
3143 };
3144 let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
3145 tokio::join!(waiter, driver)
3146 })
3147 .await
3148 .expect("wait_for_exit must not hang when sender is dropped");
3149 assert!(matches!(status, SessionExitStatus::Running));
3150 }
3151}