1use std::collections::{HashMap, VecDeque};
252use std::future::Future;
253use std::pin::Pin;
254use std::process::{ExitStatus, Stdio};
255use std::sync::Arc;
256use std::time::Duration;
257
258use serde_json::Value;
259use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
260use tokio::process::{Child, ChildStdin, ChildStdout, Command};
261use tokio::sync::{broadcast, mpsc, oneshot, watch};
262use tokio::task::JoinHandle;
263use tokio::time::Instant;
264use tracing::{Instrument, debug, warn};
265
266use crate::Claude;
267use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
268use crate::error::{DuplexLimit, DuplexOperation, Error, Result};
269use crate::tool_pattern::ToolPattern;
270use crate::types::{Effort, HermeticScope, PermissionMode};
271
272pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
277
278#[derive(Debug, Clone)]
287pub struct PermissionRequest {
288 pub request_id: String,
291 pub tool_name: String,
293 pub input: Value,
295 pub raw: Value,
298}
299
300#[derive(Debug, Clone)]
309pub enum PermissionDecision {
310 Allow {
312 updated_input: Option<Value>,
315 },
316 Deny {
318 message: String,
320 },
321 Defer,
324}
325
326type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
327type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
328
329#[derive(Clone)]
348pub struct PermissionHandler {
349 inner: Arc<PermissionFn>,
350}
351
352impl PermissionHandler {
353 pub fn new<F, Fut>(f: F) -> Self
369 where
370 F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
371 Fut: Future<Output = PermissionDecision> + Send + 'static,
372 {
373 Self {
374 inner: Arc::new(move |req| Box::pin(f(req))),
375 }
376 }
377
378 fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
379 (self.inner)(req)
380 }
381}
382
383impl std::fmt::Debug for PermissionHandler {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 f.debug_struct("PermissionHandler").finish_non_exhaustive()
386 }
387}
388
389#[derive(Debug, Default, Clone)]
424pub struct DuplexOptions {
425 shared: SharedSpawnArgs,
428 additional_args: Vec<String>,
429 subscriber_capacity: Option<usize>,
430 on_permission: Option<PermissionHandler>,
431 include_partial_messages: bool,
432 policy: DuplexPolicy,
433}
434
435#[derive(Debug, Default, Clone, Copy)]
438struct DuplexPolicy {
439 max_frame_bytes: Option<usize>,
440 max_turn_bytes: Option<usize>,
441 max_turn_events: Option<usize>,
442 turn_timeout: Option<Duration>,
443 control_timeout: Option<Duration>,
444}
445
446impl DuplexOptions {
447 #[must_use]
449 pub fn model(mut self, model: impl Into<String>) -> Self {
450 self.shared.model = Some(model.into());
451 self
452 }
453
454 #[must_use]
456 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
457 self.shared.system_prompt = Some(prompt.into());
458 self
459 }
460
461 #[must_use]
463 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
464 self.shared.append_system_prompt = Some(prompt.into());
465 self
466 }
467
468 #[must_use]
485 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
486 self.shared.resume = Some(session_id.into());
487 self
488 }
489
490 #[must_use]
497 pub fn continue_session(mut self) -> Self {
498 self.shared.continue_session = true;
499 self
500 }
501
502 #[must_use]
513 pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
514 self.shared.worktree = true;
515 if let Some(n) = name {
516 self.shared.worktree_name = Some(n.into());
517 }
518 self
519 }
520
521 #[must_use]
535 pub fn agent(mut self, name: impl Into<String>) -> Self {
536 self.shared.agent = Some(name.into());
537 self
538 }
539
540 #[must_use]
552 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
553 self.shared.agents_json = Some(json.into());
554 self
555 }
556
557 #[must_use]
573 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
574 self.shared.permission_mode = Some(mode);
575 self
576 }
577
578 #[must_use]
582 pub fn forward_subagent_text(mut self) -> Self {
583 self.shared.forward_subagent_text = true;
584 self
585 }
586
587 #[must_use]
591 pub fn allow_dangerously_skip_permissions(mut self) -> Self {
592 self.shared.allow_dangerously_skip_permissions = true;
593 self
594 }
595
596 #[must_use]
604 pub fn dangerously_skip_permissions(mut self) -> Self {
605 self.shared.dangerously_skip_permissions = true;
606 self
607 }
608
609 #[must_use]
619 pub fn session_id(mut self, id: impl Into<String>) -> Self {
620 self.shared.session_id = Some(id.into());
621 self
622 }
623
624 #[must_use]
632 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
633 self.shared.json_schema = Some(schema.into());
634 self
635 }
636
637 #[must_use]
645 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
646 where
647 I: IntoIterator<Item = T>,
648 T: Into<ToolPattern>,
649 {
650 self.shared
651 .allowed_tools
652 .extend(tools.into_iter().map(Into::into));
653 self
654 }
655
656 #[must_use]
658 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
659 self.shared.allowed_tools.push(tool.into());
660 self
661 }
662
663 #[must_use]
665 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
666 where
667 I: IntoIterator<Item = T>,
668 T: Into<ToolPattern>,
669 {
670 self.shared
671 .disallowed_tools
672 .extend(tools.into_iter().map(Into::into));
673 self
674 }
675
676 #[must_use]
678 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
679 self.shared.disallowed_tools.push(tool.into());
680 self
681 }
682
683 #[must_use]
689 pub fn max_turns(mut self, turns: u32) -> Self {
690 self.shared.max_turns = Some(turns);
691 self
692 }
693
694 #[must_use]
704 pub fn max_budget_usd(mut self, budget: f64) -> Self {
705 self.shared.max_budget_usd = Some(budget);
706 self
707 }
708
709 #[must_use]
712 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
713 self.shared.fallback_model = Some(model.into());
714 self
715 }
716
717 #[must_use]
719 pub fn effort(mut self, effort: Effort) -> Self {
720 self.shared.effort = Some(effort);
721 self
722 }
723
724 #[must_use]
727 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
728 self.shared.add_dir.push(dir.into());
729 self
730 }
731
732 #[must_use]
737 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
738 self.shared.mcp_config.push(path.into());
739 self
740 }
741
742 #[must_use]
746 pub fn strict_mcp_config(mut self) -> Self {
747 self.shared.strict_mcp_config = true;
748 self
749 }
750
751 #[must_use]
757 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
758 self.shared.setting_sources = Some(sources.into());
759 self
760 }
761
762 #[must_use]
775 pub fn hermetic(mut self) -> Self {
776 self.shared.apply_hermetic(HermeticScope::Full);
777 self
778 }
779
780 #[must_use]
786 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
787 self.shared.apply_hermetic(scope);
788 self
789 }
790
791 #[must_use]
794 pub fn no_session_persistence(mut self) -> Self {
795 self.shared.no_session_persistence = true;
796 self
797 }
798
799 #[must_use]
807 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
808 self.shared.tools.extend(tools.into_iter().map(Into::into));
809 self
810 }
811
812 #[must_use]
817 pub fn file(mut self, spec: impl Into<String>) -> Self {
818 self.shared.file.push(spec.into());
819 self
820 }
821
822 #[must_use]
826 pub fn settings(mut self, settings: impl Into<String>) -> Self {
827 self.shared.settings = Some(settings.into());
828 self
829 }
830
831 #[must_use]
838 pub fn fork_session(mut self) -> Self {
839 self.shared.fork_session = true;
840 self
841 }
842
843 #[must_use]
847 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
848 self.shared.debug_filter = Some(filter.into());
849 self
850 }
851
852 #[must_use]
856 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
857 self.shared.debug_file = Some(path.into());
858 self
859 }
860
861 #[must_use]
865 pub fn betas(mut self, betas: impl Into<String>) -> Self {
866 self.shared.betas = Some(betas.into());
867 self
868 }
869
870 #[must_use]
874 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
875 self.shared.plugin_dirs.push(dir.into());
876 self
877 }
878
879 #[must_use]
883 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
884 self.shared.plugin_urls.push(url.into());
885 self
886 }
887
888 #[must_use]
892 pub fn tmux(mut self) -> Self {
893 self.shared.tmux = true;
894 self
895 }
896
897 #[must_use]
905 pub fn bare(mut self) -> Self {
906 self.shared.bare = true;
907 self
908 }
909
910 #[must_use]
917 pub fn safe_mode(mut self) -> Self {
918 self.shared.safe_mode = true;
919 self
920 }
921
922 #[must_use]
927 pub fn disable_slash_commands(mut self) -> Self {
928 self.shared.disable_slash_commands = true;
929 self
930 }
931
932 #[must_use]
939 pub fn include_hook_events(mut self) -> Self {
940 self.shared.include_hook_events = true;
941 self
942 }
943
944 #[must_use]
953 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
954 self.shared.exclude_dynamic_system_prompt_sections = true;
955 self
956 }
957
958 #[must_use]
962 pub fn name(mut self, name: impl Into<String>) -> Self {
963 self.shared.name = Some(name.into());
964 self
965 }
966
967 #[must_use]
972 pub fn arg(mut self, arg: impl Into<String>) -> Self {
973 self.additional_args.push(arg.into());
974 self
975 }
976
977 #[must_use]
985 pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
986 self.subscriber_capacity = Some(capacity);
987 self
988 }
989
990 #[must_use]
1008 pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
1009 self.on_permission = Some(handler);
1010 self
1011 }
1012
1013 #[must_use]
1023 pub fn include_partial_messages(mut self) -> Self {
1024 self.include_partial_messages = true;
1025 self
1026 }
1027
1028 #[must_use]
1041 pub fn max_frame_bytes(mut self, bytes: usize) -> Self {
1042 self.policy.max_frame_bytes = Some(bytes);
1043 self
1044 }
1045
1046 #[must_use]
1056 pub fn max_turn_bytes(mut self, bytes: usize) -> Self {
1057 self.policy.max_turn_bytes = Some(bytes);
1058 self
1059 }
1060
1061 #[must_use]
1072 pub fn max_turn_events(mut self, events: usize) -> Self {
1073 self.policy.max_turn_events = Some(events);
1074 self
1075 }
1076
1077 #[must_use]
1096 pub fn turn_timeout(mut self, timeout: Duration) -> Self {
1097 self.policy.turn_timeout = Some(timeout);
1098 self
1099 }
1100
1101 #[must_use]
1113 pub fn control_timeout(mut self, timeout: Duration) -> Self {
1114 self.policy.control_timeout = Some(timeout);
1115 self
1116 }
1117
1118 fn build_args(&self) -> Vec<String> {
1119 let mut args = vec![
1120 "--print".to_string(),
1121 "--verbose".to_string(),
1122 "--output-format".to_string(),
1123 "stream-json".to_string(),
1124 "--input-format".to_string(),
1125 "stream-json".to_string(),
1126 ];
1127
1128 self.shared.append_to(&mut args);
1129
1130 if self.include_partial_messages {
1131 args.push("--include-partial-messages".to_string());
1132 }
1133 if self.on_permission.is_some() {
1134 args.push("--permission-prompt-tool".to_string());
1135 args.push("stdio".to_string());
1136 }
1137 args.extend(self.additional_args.iter().cloned());
1138
1139 args
1140 }
1141
1142 fn spawn_command_args(&self, claude: &Claude) -> Vec<String> {
1148 let mut args = claude.global_args.clone();
1149 args.extend(self.build_args());
1150 args
1151 }
1152
1153 #[must_use]
1182 pub fn to_command_string(&self, claude: &Claude) -> String {
1183 let args = self.spawn_command_args(claude);
1184 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
1185 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
1186 }
1187}
1188
1189#[derive(Debug, Clone)]
1196pub struct TurnResult {
1197 pub result: Value,
1199 pub events: Vec<Value>,
1201}
1202
1203impl TurnResult {
1204 #[must_use]
1206 pub fn result_text(&self) -> Option<&str> {
1207 self.result.get("result").and_then(Value::as_str)
1208 }
1209
1210 #[must_use]
1212 pub fn session_id(&self) -> Option<&str> {
1213 self.result.get("session_id").and_then(Value::as_str)
1214 }
1215
1216 #[must_use]
1232 pub fn total_cost_usd(&self) -> Option<f64> {
1233 self.result
1234 .get("total_cost_usd")
1235 .or_else(|| self.result.get("cost_usd"))
1236 .and_then(Value::as_f64)
1237 }
1238
1239 #[must_use]
1241 pub fn duration_ms(&self) -> Option<u64> {
1242 self.result.get("duration_ms").and_then(Value::as_u64)
1243 }
1244
1245 #[must_use]
1249 pub fn subtype(&self) -> Option<&str> {
1250 self.result.get("subtype").and_then(Value::as_str)
1251 }
1252
1253 #[must_use]
1256 pub fn is_error(&self) -> bool {
1257 self.result
1258 .get("is_error")
1259 .and_then(Value::as_bool)
1260 .unwrap_or(false)
1261 }
1262}
1263
1264pub(crate) fn turn_cost_delta(prev_total: &mut Option<f64>, total: Option<f64>) -> Option<f64> {
1272 let total = total?;
1273 let prev = prev_total.replace(total).unwrap_or(0.0);
1274 Some((total - prev).max(0.0))
1275}
1276
1277#[derive(Debug, Clone)]
1291pub enum InboundEvent {
1292 SystemInit {
1295 session_id: String,
1298 },
1299 Assistant(Value),
1302 StreamEvent(Value),
1305 User(Value),
1308 Other(Value),
1311}
1312
1313fn classify(msg: &Value) -> InboundEvent {
1314 match msg.get("type").and_then(Value::as_str) {
1315 Some("system") => {
1316 if msg.get("subtype").and_then(Value::as_str) == Some("init")
1317 && let Some(id) = msg.get("session_id").and_then(Value::as_str)
1318 {
1319 return InboundEvent::SystemInit {
1320 session_id: id.to_string(),
1321 };
1322 }
1323 InboundEvent::Other(msg.clone())
1324 }
1325 Some("assistant") => InboundEvent::Assistant(msg.clone()),
1326 Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
1327 Some("user") => InboundEvent::User(msg.clone()),
1328 _ => InboundEvent::Other(msg.clone()),
1329 }
1330}
1331
1332#[derive(Debug, Clone)]
1347pub enum SessionExitStatus {
1348 Running,
1350 Completed,
1352 Failed(String),
1355}
1356
1357#[derive(Debug)]
1366pub struct DuplexSession {
1367 outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
1368 events_tx: broadcast::Sender<InboundEvent>,
1369 exit_rx: watch::Receiver<SessionExitStatus>,
1370 join: JoinHandle<Result<()>>,
1371}
1372
1373#[derive(Debug)]
1374enum OutboundMsg {
1375 Send {
1376 prompt: String,
1377 reply: oneshot::Sender<Result<TurnResult>>,
1378 },
1379 PermissionResponse {
1380 request_id: String,
1381 decision: PermissionDecision,
1382 },
1383 Interrupt {
1384 reply: oneshot::Sender<Result<()>>,
1385 },
1386}
1387
1388impl DuplexSession {
1389 pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
1397 let capacity = opts
1398 .subscriber_capacity
1399 .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
1400 let permission_handler = opts.on_permission.clone();
1401
1402 let command_args = opts.spawn_command_args(claude);
1403
1404 debug!(
1405 arg_count = command_args.len(),
1406 "spawning duplex claude session"
1407 );
1408
1409 let mut cmd = Command::new(&claude.binary);
1410 cmd.args(&command_args)
1411 .stdin(Stdio::piped())
1412 .stdout(Stdio::piped())
1413 .stderr(Stdio::piped())
1414 .kill_on_drop(true);
1415 crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
1416 crate::exec::apply_process_group(&mut cmd, claude.process_group);
1420
1421 if let Some(ref dir) = claude.working_dir {
1422 cmd.current_dir(dir);
1423 }
1424
1425 let mut child = crate::exec::spawn_retrying_txtbsy(&mut cmd)
1426 .await
1427 .map_err(|e| Error::Io {
1428 message: format!("failed to spawn claude: {e}"),
1429 source: e,
1430 working_dir: claude.working_dir.clone(),
1431 })?;
1432 let group =
1433 crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
1434
1435 let stdin = child.stdin.take().expect("stdin was piped");
1436 let stdout = child.stdout.take().expect("stdout was piped");
1437
1438 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1439 let (events_tx, _initial_rx) = broadcast::channel(capacity);
1440 let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1441
1442 let session_span = tracing::debug_span!(
1451 "claude.session",
1452 model_configured = opts.shared.model.is_some(),
1453 permission_mode = opts
1454 .shared
1455 .permission_mode
1456 .as_ref()
1457 .map(|m| m.as_arg())
1458 .unwrap_or("default"),
1459 resumed = opts.shared.resume.is_some(),
1460 turns = tracing::field::Empty,
1461 exit = tracing::field::Empty,
1462 );
1463 let join = tokio::spawn(
1464 run_session(
1465 child,
1466 group,
1467 claude.kill_grace,
1468 claude.termination_signal,
1469 stdin,
1470 stdout,
1471 outbound_rx,
1472 events_tx.clone(),
1473 permission_handler,
1474 opts.policy,
1475 exit_tx,
1476 )
1477 .instrument(session_span),
1478 );
1479
1480 Ok(Self {
1481 outbound_tx,
1482 events_tx,
1483 exit_rx,
1484 join,
1485 })
1486 }
1487
1488 pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1510 let (reply_tx, reply_rx) = oneshot::channel();
1511 self.outbound_tx
1512 .send(OutboundMsg::Send {
1513 prompt: prompt.into(),
1514 reply: reply_tx,
1515 })
1516 .map_err(|_| Error::DuplexClosed)?;
1517 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1518 }
1519
1520 #[must_use]
1555 pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1556 self.events_tx.subscribe()
1557 }
1558
1559 #[must_use]
1570 pub fn is_alive(&self) -> bool {
1571 matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1572 }
1573
1574 #[must_use]
1583 pub fn exit_status(&self) -> SessionExitStatus {
1584 self.exit_rx.borrow().clone()
1585 }
1586
1587 pub async fn wait_for_exit(&self) -> SessionExitStatus {
1599 let mut rx = self.exit_rx.clone();
1600 loop {
1601 {
1602 let value = rx.borrow_and_update();
1603 if !matches!(*value, SessionExitStatus::Running) {
1604 return value.clone();
1605 }
1606 }
1607 if rx.changed().await.is_err() {
1608 return rx.borrow().clone();
1609 }
1610 }
1611 }
1612
1613 pub fn respond_to_permission(
1658 &self,
1659 request_id: impl Into<String>,
1660 decision: PermissionDecision,
1661 ) -> Result<()> {
1662 if matches!(decision, PermissionDecision::Defer) {
1663 warn!("respond_to_permission called with Defer; ignoring");
1664 return Ok(());
1665 }
1666 self.outbound_tx
1667 .send(OutboundMsg::PermissionResponse {
1668 request_id: request_id.into(),
1669 decision,
1670 })
1671 .map_err(|_| Error::DuplexClosed)?;
1672 Ok(())
1673 }
1674
1675 pub async fn interrupt(&self) -> Result<()> {
1724 let (reply_tx, reply_rx) = oneshot::channel();
1725 self.outbound_tx
1726 .send(OutboundMsg::Interrupt { reply: reply_tx })
1727 .map_err(|_| Error::DuplexClosed)?;
1728 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1729 }
1730
1731 pub async fn close(self) -> Result<()> {
1743 drop(self.outbound_tx);
1744 drop(self.events_tx);
1745 match self.join.await {
1746 Ok(result) => result,
1747 Err(e) if e.is_cancelled() => Ok(()),
1748 Err(e) => Err(Error::Io {
1749 message: format!("duplex session task panicked: {e}"),
1750 source: std::io::Error::other(e.to_string()),
1751 working_dir: None,
1752 }),
1753 }
1754 }
1755}
1756
1757const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1761
1762const STDERR_TAIL_BYTES: usize = 64 * 1024;
1765const STDERR_SETTLE_BUDGET: Duration = Duration::from_millis(200);
1766
1767async fn read_stderr_tail<R: tokio::io::AsyncRead + Unpin>(
1768 reader: &mut R,
1769 tail: &mut VecDeque<u8>,
1770) -> std::io::Result<()> {
1771 let mut chunk = [0u8; 8192];
1772 loop {
1773 let read = match reader.read(&mut chunk).await {
1774 Ok(0) => return Ok(()),
1775 Ok(read) => read,
1776 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1777 Err(error) => return Err(error),
1778 };
1779 let excess = (tail.len() + read).saturating_sub(STDERR_TAIL_BYTES);
1780 tail.drain(..excess);
1781 tail.extend(&chunk[..read]);
1782 }
1783}
1784
1785#[allow(clippy::too_many_arguments)]
1786async fn run_session(
1787 mut child: Child,
1788 group: crate::exec::GroupKillGuard,
1789 kill_grace: Option<Duration>,
1790 termination_signal: crate::TerminationSignal,
1791 stdin: ChildStdin,
1792 stdout: ChildStdout,
1793 outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1794 events_tx: broadcast::Sender<InboundEvent>,
1795 permission_handler: Option<PermissionHandler>,
1796 policy: DuplexPolicy,
1797 exit_tx: watch::Sender<SessionExitStatus>,
1798) -> Result<()> {
1799 let mut stderr = child.stderr.take().expect("stderr was piped");
1800 let mut tail = VecDeque::new();
1801 let (stderr_failed_tx, stderr_failed_rx) = oneshot::channel();
1802 let (status, stderr_result) = {
1805 let drain_stderr = async {
1806 let result = read_stderr_tail(&mut stderr, &mut tail).await;
1807 if result.is_err() {
1808 let _ = stderr_failed_tx.send(());
1809 }
1810 result
1811 };
1812 let session = run_session_child(
1813 child,
1814 group,
1815 kill_grace,
1816 termination_signal,
1817 stdin,
1818 stdout,
1819 outbound_rx,
1820 events_tx,
1821 permission_handler,
1822 policy,
1823 stderr_failed_rx,
1824 );
1825 tokio::pin!(drain_stderr);
1826 tokio::pin!(session);
1827 tokio::select! {
1828 status = &mut session => {
1829 let read = tokio::time::timeout(
1832 STDERR_SETTLE_BUDGET, &mut drain_stderr,
1833 ).await.ok();
1834 (status, read)
1835 }
1836 read = &mut drain_stderr => (session.await, Some(read)),
1837 }
1838 };
1839 let stderr = String::from_utf8_lossy(&tail.into_iter().collect::<Vec<_>>()).into_owned();
1840 let result = resident_outcome(status, stderr_result, stderr);
1841 let final_state = match &result {
1842 Ok(()) => SessionExitStatus::Completed,
1843 Err(error) => SessionExitStatus::Failed(error.to_string()),
1844 };
1845 tracing::Span::current().record(
1846 "exit",
1847 if result.is_ok() {
1848 "completed"
1849 } else {
1850 "failed"
1851 },
1852 );
1853 let _ = exit_tx.send(final_state);
1854 result
1855}
1856
1857fn resident_outcome(
1858 status: Result<(ExitStatus, bool)>,
1859 stderr_result: Option<std::io::Result<()>>,
1860 stderr: String,
1861) -> Result<()> {
1862 match (status, stderr_result) {
1863 (Err(error), _) => Err(error),
1864 (Ok((status, shutdown_budget_exceeded)), read) if !status.success() => {
1865 Err(Error::DuplexProcessFailed {
1866 status,
1867 shutdown_budget_exceeded,
1868 stderr,
1869 stderr_read_error: read.and_then(std::result::Result::err),
1870 })
1871 }
1872 (Ok(_), Some(Err(source))) => Err(Error::Io {
1873 message: format!("failed to read duplex stderr; retained diagnostics: {stderr}"),
1874 source,
1875 working_dir: None,
1876 }),
1877 (Ok(_), _) => Ok(()),
1878 }
1879}
1880
1881#[derive(Debug, Clone, Copy, PartialEq)]
1883struct OpDeadline {
1884 at: Instant,
1885 operation: DuplexOperation,
1886 timeout: Duration,
1887}
1888
1889impl OpDeadline {
1890 fn start(operation: DuplexOperation, timeout: Option<Duration>) -> Option<Self> {
1893 let timeout = timeout?;
1894 Some(Self {
1895 at: Instant::now().checked_add(timeout)?,
1896 operation,
1897 timeout,
1898 })
1899 }
1900
1901 fn failure(self) -> PolicyFailure {
1902 PolicyFailure::Deadline {
1903 operation: self.operation,
1904 timeout: self.timeout,
1905 }
1906 }
1907}
1908
1909#[derive(Debug, Clone, Copy)]
1912enum PolicyFailure {
1913 Limit {
1914 limit: DuplexLimit,
1915 max: usize,
1916 },
1917 Deadline {
1918 operation: DuplexOperation,
1919 timeout: Duration,
1920 },
1921}
1922
1923impl PolicyFailure {
1924 fn error(self, process_settled: bool) -> Error {
1925 match self {
1926 Self::Limit { limit, max } => Error::DuplexLimitExceeded {
1927 limit,
1928 max,
1929 process_settled,
1930 },
1931 Self::Deadline { operation, timeout } => Error::DuplexDeadlineExceeded {
1932 operation,
1933 timeout,
1934 process_settled,
1935 },
1936 }
1937 }
1938}
1939
1940#[derive(Debug, Default)]
1943struct TurnBytes {
1944 limit: Option<usize>,
1945 used: Option<usize>,
1946}
1947
1948impl TurnBytes {
1949 fn start(&mut self) {
1950 self.used = Some(0);
1951 }
1952
1953 fn stop(&mut self) {
1954 self.used = None;
1955 }
1956
1957 fn charge(&mut self, bytes: usize) -> std::result::Result<(), PolicyFailure> {
1958 if let Some(used) = self.used.as_mut() {
1959 let total = used.saturating_add(bytes);
1960 if let Some(max) = self.limit
1961 && total > max
1962 {
1963 return Err(PolicyFailure::Limit {
1964 limit: DuplexLimit::TurnBytes,
1965 max,
1966 });
1967 }
1968 *used = total;
1969 }
1970 Ok(())
1971 }
1972}
1973
1974enum FrameRead {
1975 Frame(Vec<u8>),
1977 Eof,
1978}
1979
1980enum FrameError {
1981 Io(std::io::Error),
1982 Limit(PolicyFailure),
1983}
1984
1985struct FrameReader<R> {
1993 reader: BufReader<R>,
1994 partial: Vec<u8>,
1995 max_frame_bytes: Option<usize>,
1996}
1997
1998impl<R: tokio::io::AsyncRead + Unpin> FrameReader<R> {
1999 fn new(reader: R, max_frame_bytes: Option<usize>) -> Self {
2000 Self {
2001 reader: BufReader::new(reader),
2002 partial: Vec::new(),
2003 max_frame_bytes,
2004 }
2005 }
2006
2007 async fn next_frame(
2008 &mut self,
2009 turn: &mut TurnBytes,
2010 ) -> std::result::Result<FrameRead, FrameError> {
2011 if turn.used == Some(0) {
2014 turn.charge(self.partial.len()).map_err(FrameError::Limit)?;
2015 }
2016 loop {
2017 let available = match self.reader.fill_buf().await {
2018 Ok(available) => available,
2019 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
2020 Err(error) => return Err(FrameError::Io(error)),
2021 };
2022 if available.is_empty() {
2023 if self.partial.is_empty() {
2024 return Ok(FrameRead::Eof);
2025 }
2026 return Self::validate_frame(std::mem::take(&mut self.partial));
2028 }
2029 let (take, complete) = match available.iter().position(|&byte| byte == b'\n') {
2030 Some(end) => (end + 1, true),
2031 None => (available.len(), false),
2032 };
2033 if let Some(max) = self.max_frame_bytes
2034 && self.partial.len().saturating_add(take) > max
2035 {
2036 return Err(FrameError::Limit(PolicyFailure::Limit {
2037 limit: DuplexLimit::FrameBytes,
2038 max,
2039 }));
2040 }
2041 turn.charge(take).map_err(FrameError::Limit)?;
2042 self.partial.extend_from_slice(&available[..take]);
2043 self.reader.consume(take);
2044 if complete {
2045 let mut frame = std::mem::take(&mut self.partial);
2046 frame.pop();
2047 if frame.last() == Some(&b'\r') {
2048 frame.pop();
2049 }
2050 return Self::validate_frame(frame);
2051 }
2052 }
2053 }
2054
2055 fn validate_frame(frame: Vec<u8>) -> std::result::Result<FrameRead, FrameError> {
2056 std::str::from_utf8(&frame).map_err(|error| {
2058 FrameError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
2059 })?;
2060 Ok(FrameRead::Frame(frame))
2061 }
2062
2063 fn into_inner(self) -> BufReader<R> {
2064 self.reader
2065 }
2066}
2067
2068type PendingTurn = Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>;
2069type PendingControl = HashMap<String, (oneshot::Sender<Result<()>>, Option<OpDeadline>)>;
2070
2071enum IoEnd {
2073 Eof,
2074 Closed,
2075 StderrFailed,
2076 Read(Error),
2077 Policy(PolicyFailure),
2078}
2079
2080struct Resident {
2084 stdin: ChildStdin,
2085 frames: FrameReader<ChildStdout>,
2086 turn_bytes: TurnBytes,
2087 pending: PendingTurn,
2088 turn_deadline: Option<OpDeadline>,
2089 pending_control: PendingControl,
2090 next_control_id: u64,
2091 deadline_tx: watch::Sender<Option<OpDeadline>>,
2092 policy: DuplexPolicy,
2093 session_span: tracing::Span,
2094 turns: u64,
2095 turn_span: Option<tracing::Span>,
2096 turn_started: Option<std::time::Instant>,
2097 last_total_cost: Option<f64>,
2100}
2101
2102impl Resident {
2103 fn earliest_deadline(&self) -> Option<OpDeadline> {
2104 self.turn_deadline
2105 .into_iter()
2106 .chain(self.pending_control.values().filter_map(|(_, d)| *d))
2107 .min_by_key(|deadline| deadline.at)
2108 }
2109
2110 fn expired_deadline(&self) -> Option<PolicyFailure> {
2111 self.earliest_deadline()
2112 .filter(|deadline| deadline.at <= Instant::now())
2113 .map(OpDeadline::failure)
2114 }
2115
2116 fn publish_deadline(&self) {
2119 let next = self.earliest_deadline();
2120 self.deadline_tx.send_if_modified(|current| {
2121 let changed = *current != next;
2122 *current = next;
2123 changed
2124 });
2125 }
2126
2127 fn finish_turn(&mut self) {
2128 self.turn_deadline = None;
2129 self.turn_bytes.stop();
2130 self.publish_deadline();
2131 }
2132
2133 fn record_turn_outcome(&mut self, result: &Value) {
2134 let total = result
2135 .get("total_cost_usd")
2136 .or_else(|| result.get("cost_usd"))
2137 .and_then(Value::as_f64);
2138 let cost = turn_cost_delta(&mut self.last_total_cost, total);
2139 let Some(span) = self.turn_span.take() else {
2140 return;
2141 };
2142 span.record(
2143 "is_error",
2144 result
2145 .get("is_error")
2146 .and_then(Value::as_bool)
2147 .unwrap_or(false),
2148 );
2149 if let Some(sub) = result.get("subtype").and_then(Value::as_str) {
2150 let label = match sub {
2151 "success" => "success",
2152 "error_during_execution" => "error_during_execution",
2153 "error_max_turns" => "error_max_turns",
2154 "error_max_budget_usd" => "error_max_budget_usd",
2155 _ => "other",
2156 };
2157 span.record("subtype", label);
2158 }
2159 if let (Some(c), Some(t)) = (cost, total) {
2160 span.record("cost_usd", c);
2161 span.record("total_cost_usd", t);
2162 }
2163 if let Some(started) = self.turn_started.take() {
2164 span.record("duration_ms", started.elapsed().as_millis() as u64);
2165 }
2166 }
2167
2168 async fn handle_frame(
2169 &mut self,
2170 frame: &[u8],
2171 events_tx: &broadcast::Sender<InboundEvent>,
2172 permission_handler: Option<&PermissionHandler>,
2173 ) -> Option<PolicyFailure> {
2174 if frame.iter().all(u8::is_ascii_whitespace) {
2175 return None;
2176 }
2177 let parsed = match serde_json::from_slice::<Value>(frame) {
2178 Ok(v) => v,
2179 Err(e) => {
2180 debug!(line_bytes = frame.len(), json_error = ?e.classify(), "failed to parse duplex event, skipping");
2181 return None;
2182 }
2183 };
2184 if let Some(failure) = self.expired_deadline() {
2187 return Some(failure);
2188 }
2189 let is_result = parsed.get("type").and_then(Value::as_str) == Some("result");
2191 if is_result {
2192 self.record_turn_outcome(&parsed);
2193 }
2194 match handle_inbound(
2195 parsed,
2196 &mut self.pending,
2197 events_tx,
2198 self.policy.max_turn_events,
2199 ) {
2200 InboundAction::None => {}
2201 InboundAction::EventLimit(max) => {
2202 return Some(PolicyFailure::Limit {
2203 limit: DuplexLimit::TurnEvents,
2204 max,
2205 });
2206 }
2207 InboundAction::Permission(req) => {
2208 let request_id = req.request_id.clone();
2209 let decision = match permission_handler {
2210 Some(h) => h.invoke(req).await,
2211 None => {
2212 warn!("received can_use_tool with no permission handler; auto-denying");
2213 PermissionDecision::Deny {
2214 message: "no permission handler configured on duplex session".into(),
2215 }
2216 }
2217 };
2218 if matches!(decision, PermissionDecision::Defer) {
2219 debug!("permission handler deferred; waiting for respond_to_permission");
2220 } else if write_permission_response(&mut self.stdin, &request_id, &decision)
2221 .await
2222 .is_err()
2223 {
2224 warn!("failed to write permission response");
2225 }
2226 }
2227 InboundAction::ControlResponse {
2228 request_id,
2229 outcome,
2230 } => {
2231 if let Some((reply, _)) = self.pending_control.remove(&request_id) {
2232 let _ = reply.send(outcome);
2233 self.publish_deadline();
2234 } else {
2235 debug!("received control_response with no pending request");
2236 }
2237 }
2238 }
2239 if is_result && self.pending.is_none() {
2240 self.finish_turn();
2241 }
2242 None
2243 }
2244
2245 async fn handle_outbound(&mut self, msg: OutboundMsg) {
2246 match msg {
2247 OutboundMsg::Send { prompt, reply } => {
2248 if self.pending.is_some() {
2249 let _ = reply.send(Err(Error::DuplexTurnInFlight));
2250 return;
2251 }
2252 self.pending = Some((reply, Vec::new()));
2255 self.turn_deadline =
2256 OpDeadline::start(DuplexOperation::Turn, self.policy.turn_timeout);
2257 self.turn_bytes.start();
2258 self.publish_deadline();
2259 if let Err(e) = write_user(&mut self.stdin, &prompt).await {
2260 if let Some((reply, _)) = self.pending.take() {
2261 let _ = reply.send(Err(e));
2262 }
2263 self.finish_turn();
2264 return;
2265 }
2266 self.turns += 1;
2267 self.turn_span = Some(tracing::debug_span!(
2272 parent: &self.session_span,
2273 "claude.turn",
2274 turn = self.turns,
2275 is_error = tracing::field::Empty,
2276 subtype = tracing::field::Empty,
2277 cost_usd = tracing::field::Empty,
2278 total_cost_usd = tracing::field::Empty,
2279 duration_ms = tracing::field::Empty,
2280 ));
2281 self.turn_started = Some(std::time::Instant::now());
2282 }
2283 OutboundMsg::PermissionResponse {
2284 request_id,
2285 decision,
2286 } => {
2287 if write_permission_response(&mut self.stdin, &request_id, &decision)
2288 .await
2289 .is_err()
2290 {
2291 warn!("failed to write deferred permission response");
2292 }
2293 }
2294 OutboundMsg::Interrupt { reply } => {
2295 self.next_control_id += 1;
2296 let request_id = format!("interrupt-{}", self.next_control_id);
2297 let deadline =
2298 OpDeadline::start(DuplexOperation::Control, self.policy.control_timeout);
2299 self.pending_control
2300 .insert(request_id.clone(), (reply, deadline));
2301 self.publish_deadline();
2302 if let Err(e) =
2303 write_control_request(&mut self.stdin, &request_id, "interrupt").await
2304 {
2305 if let Some((reply, _)) = self.pending_control.remove(&request_id) {
2306 let _ = reply.send(Err(e));
2307 }
2308 self.publish_deadline();
2309 }
2310 }
2311 }
2312 }
2313}
2314
2315async fn pump(
2319 res: &mut Resident,
2320 outbound_rx: &mut mpsc::UnboundedReceiver<OutboundMsg>,
2321 events_tx: &broadcast::Sender<InboundEvent>,
2322 permission_handler: Option<&PermissionHandler>,
2323 stderr_failed: &mut oneshot::Receiver<()>,
2324) -> IoEnd {
2325 let mut stderr_failure_open = true;
2326 loop {
2327 if let Some(failure) = res.expired_deadline() {
2328 return IoEnd::Policy(failure);
2329 }
2330 tokio::select! {
2333 frame = res.frames.next_frame(&mut res.turn_bytes) => match frame {
2334 Ok(FrameRead::Frame(frame)) => {
2335 if let Some(failure) =
2336 res.handle_frame(&frame, events_tx, permission_handler).await
2337 {
2338 return IoEnd::Policy(failure);
2339 }
2340 tokio::task::consume_budget().await;
2343 }
2344 Ok(FrameRead::Eof) => return IoEnd::Eof,
2345 Err(FrameError::Io(source)) => {
2346 return IoEnd::Read(Error::Io {
2347 message: "failed to read duplex stdout".to_string(),
2348 source,
2349 working_dir: None,
2350 });
2351 }
2352 Err(FrameError::Limit(failure)) => return IoEnd::Policy(failure),
2353 },
2354
2355 failure = &mut *stderr_failed, if stderr_failure_open => {
2356 stderr_failure_open = false;
2357 if failure.is_ok() {
2358 return IoEnd::StderrFailed;
2359 }
2360 },
2361
2362 msg = outbound_rx.recv() => match msg {
2363 Some(msg) => res.handle_outbound(msg).await,
2364 None => return IoEnd::Closed,
2365 },
2366 }
2367 }
2368}
2369
2370async fn expire(deadline: Option<OpDeadline>) -> PolicyFailure {
2372 match deadline {
2373 Some(deadline) => {
2374 tokio::time::sleep_until(deadline.at).await;
2375 deadline.failure()
2376 }
2377 None => std::future::pending().await,
2378 }
2379}
2380
2381async fn supervise(
2387 pump: impl Future<Output = IoEnd>,
2388 deadline_rx: &mut watch::Receiver<Option<OpDeadline>>,
2389) -> IoEnd {
2390 tokio::pin!(pump);
2391 let mut deadlines_open = true;
2392 loop {
2393 let next = *deadline_rx.borrow_and_update();
2394 tokio::select! {
2395 biased;
2396
2397 _ = expire(next) => {
2398 if let Some(current) = *deadline_rx.borrow_and_update()
2401 && current.at <= Instant::now()
2402 {
2403 return IoEnd::Policy(current.failure());
2404 }
2405 }
2406 changed = deadline_rx.changed(), if deadlines_open => {
2407 deadlines_open = changed.is_ok();
2408 }
2409 end = &mut pump => return end,
2410 }
2411 }
2412}
2413
2414fn reject_queued(msg: OutboundMsg) {
2416 match msg {
2417 OutboundMsg::Send { reply, .. } => {
2418 let _ = reply.send(Err(Error::DuplexClosed));
2419 }
2420 OutboundMsg::Interrupt { reply } => {
2421 let _ = reply.send(Err(Error::DuplexClosed));
2422 }
2423 OutboundMsg::PermissionResponse { .. } => {}
2424 }
2425}
2426
2427#[allow(clippy::too_many_arguments)]
2428async fn run_session_child(
2429 mut child: Child,
2430 mut group: crate::exec::GroupKillGuard,
2431 kill_grace: Option<Duration>,
2432 termination_signal: crate::TerminationSignal,
2433 stdin: ChildStdin,
2434 stdout: ChildStdout,
2435 mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
2436 events_tx: broadcast::Sender<InboundEvent>,
2437 permission_handler: Option<PermissionHandler>,
2438 policy: DuplexPolicy,
2439 mut stderr_failed: oneshot::Receiver<()>,
2440) -> Result<(ExitStatus, bool)> {
2441 let (deadline_tx, mut deadline_rx) = watch::channel(None);
2442 let mut res = Resident {
2443 stdin,
2444 frames: FrameReader::new(stdout, policy.max_frame_bytes),
2445 turn_bytes: TurnBytes {
2446 limit: policy.max_turn_bytes,
2447 used: None,
2448 },
2449 pending: None,
2450 turn_deadline: None,
2451 pending_control: HashMap::new(),
2452 next_control_id: 0,
2453 deadline_tx,
2454 policy,
2455 session_span: tracing::Span::current(),
2457 turns: 0,
2458 turn_span: None,
2459 turn_started: None,
2460 last_total_cost: None,
2461 };
2462
2463 let end = supervise(
2465 pump(
2466 &mut res,
2467 &mut outbound_rx,
2468 &events_tx,
2469 permission_handler.as_ref(),
2470 &mut stderr_failed,
2471 ),
2472 &mut deadline_rx,
2473 )
2474 .await;
2475
2476 outbound_rx.close();
2479 while let Ok(msg) = outbound_rx.try_recv() {
2480 reject_queued(msg);
2481 }
2482
2483 let mut failure = None;
2484 let mut stream_err = None;
2485 match end {
2486 IoEnd::Policy(f) => failure = Some(f),
2487 IoEnd::Read(error) => stream_err = Some(error),
2488 IoEnd::Eof | IoEnd::Closed | IoEnd::StderrFailed => {}
2489 }
2490
2491 let Resident {
2492 stdin,
2493 frames,
2494 pending,
2495 pending_control,
2496 session_span,
2497 turns,
2498 ..
2499 } = res;
2500 drop(stdin);
2501 let mut stdout = frames.into_inner();
2502 let mut unused_stderr = tokio::io::empty();
2505
2506 let mut status = None;
2507 if failure.is_none() {
2508 let graceful = crate::exec::drain_until(
2510 tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()),
2511 &mut stdout,
2512 &mut unused_stderr,
2513 );
2514 let waited = tokio::select! {
2515 biased;
2516 waited = graceful => Some(waited),
2517 expired = expire(*deadline_rx.borrow()) => {
2518 failure = Some(expired);
2519 None
2520 }
2521 };
2522 if let Some(waited) = waited {
2523 let settlement = settle_graceful(
2524 waited,
2525 &mut child,
2526 &mut group,
2527 kill_grace,
2528 termination_signal,
2529 &mut stdout,
2530 );
2531 tokio::pin!(settlement);
2532 status = Some(tokio::select! {
2533 biased;
2534 settled = &mut settlement => settled,
2535 expired = expire(*deadline_rx.borrow()) => {
2536 failure = Some(expired);
2537 settlement.await
2539 }
2540 });
2541 }
2542 }
2543
2544 let mut process_settled = status.as_ref().is_some_and(Result::is_ok);
2547 if let Some(failure) = failure
2548 && status.is_none()
2549 {
2550 warn!(?failure, "duplex policy tripped; stopping child");
2551 match crate::exec::drain_until(
2552 crate::exec::stop_and_reap(
2553 &mut child,
2554 &mut group,
2555 kill_grace,
2556 termination_signal,
2557 None,
2558 ),
2559 &mut stdout,
2560 &mut unused_stderr,
2561 )
2562 .await
2563 {
2564 Ok(()) => process_settled = true,
2565 Err(error) => warn!(%error, "failed to settle duplex child after policy failure"),
2566 }
2567 }
2568
2569 let settle_error = || match failure {
2570 Some(failure) => failure.error(process_settled),
2571 None => Error::DuplexClosed,
2572 };
2573 if let Some((reply, _)) = pending {
2574 let _ = reply.send(Err(settle_error()));
2575 }
2576 for (_, (reply, _)) in pending_control {
2577 let _ = reply.send(Err(settle_error()));
2578 }
2579
2580 session_span.record("turns", turns);
2581 match (failure, stream_err, status) {
2582 (Some(failure), _, _) => Err(failure.error(process_settled)),
2583 (None, Some(error), _) => Err(error),
2584 (None, None, Some(status)) => status,
2585 (None, None, None) => unreachable!("graceful shutdown always records a status"),
2586 }
2587}
2588
2589async fn settle_graceful(
2592 waited: std::result::Result<std::io::Result<ExitStatus>, tokio::time::error::Elapsed>,
2593 child: &mut Child,
2594 group: &mut crate::exec::GroupKillGuard,
2595 kill_grace: Option<Duration>,
2596 termination_signal: crate::TerminationSignal,
2597 stdout: &mut BufReader<ChildStdout>,
2598) -> Result<(ExitStatus, bool)> {
2599 let mut unused_stderr = tokio::io::empty();
2600 match waited {
2601 Ok(Ok(status)) => {
2602 group.disarm();
2603 Ok((status, false))
2604 }
2605 Ok(Err(source)) => {
2606 let error = Error::Io {
2607 message: "failed to wait for duplex child".into(),
2608 source,
2609 working_dir: None,
2610 };
2611 match crate::exec::drain_until(
2612 crate::exec::stop_and_reap(child, group, kill_grace, termination_signal, None),
2613 stdout,
2614 &mut unused_stderr,
2615 )
2616 .await
2617 {
2618 Ok(()) => Err(error),
2619 Err(cleanup) => Err(cleanup),
2620 }
2621 }
2622 Err(_) => {
2623 warn!("duplex child did not exit within shutdown budget; stopping");
2624 match crate::exec::drain_until(
2625 crate::exec::stop_and_reap(child, group, kill_grace, termination_signal, None),
2626 stdout,
2627 &mut unused_stderr,
2628 )
2629 .await
2630 {
2631 Ok(()) => child
2632 .wait()
2633 .await
2634 .map(|status| (status, true))
2635 .map_err(|source| Error::Io {
2636 message: "failed to wait for stopped duplex child".into(),
2637 source,
2638 working_dir: None,
2639 }),
2640 Err(error) => Err(error),
2641 }
2642 }
2643 }
2644}
2645
2646enum InboundAction {
2650 None,
2652 Permission(PermissionRequest),
2656 ControlResponse {
2661 request_id: String,
2662 outcome: Result<()>,
2663 },
2664 EventLimit(usize),
2667}
2668
2669fn event_limit(pending: &PendingTurn, max_events: Option<usize>) -> Option<usize> {
2671 let (_, events) = pending.as_ref()?;
2672 max_events.filter(|max| events.len() >= *max)
2673}
2674
2675fn handle_inbound(
2676 msg: Value,
2677 pending: &mut PendingTurn,
2678 events_tx: &broadcast::Sender<InboundEvent>,
2679 max_events: Option<usize>,
2680) -> InboundAction {
2681 let kind = msg.get("type").and_then(Value::as_str);
2684 let retained = match kind {
2685 Some("result") => false,
2686 Some("control_response") => parse_control_response(&msg).is_none(),
2687 _ => true,
2688 };
2689 if retained && let Some(max) = event_limit(pending, max_events) {
2690 return InboundAction::EventLimit(max);
2691 }
2692 match kind {
2693 Some("result") => {
2694 if let Some((reply, events)) = pending.take() {
2695 let _ = reply.send(Ok(TurnResult {
2696 result: msg,
2697 events,
2698 }));
2699 } else {
2700 debug!("dropping orphan result event with no pending turn");
2701 }
2702 InboundAction::None
2703 }
2704 Some("control_request") => {
2705 if msg
2708 .get("request")
2709 .and_then(|r| r.get("subtype"))
2710 .and_then(Value::as_str)
2711 == Some("can_use_tool")
2712 && let Some(req) = parse_permission_request(&msg)
2713 {
2714 if let Some((_, events)) = pending.as_mut() {
2715 events.push(msg);
2716 }
2717 return InboundAction::Permission(req);
2718 }
2719 debug!("received unhandled control_request; treating as Other");
2720 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
2721 if let Some((_, events)) = pending.as_mut() {
2722 events.push(msg);
2723 }
2724 InboundAction::None
2725 }
2726 Some("control_response") => {
2727 if let Some((request_id, outcome)) = parse_control_response(&msg) {
2728 return InboundAction::ControlResponse {
2729 request_id,
2730 outcome,
2731 };
2732 }
2733 debug!("received malformed control_response; treating as Other");
2734 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
2735 if let Some((_, events)) = pending.as_mut() {
2736 events.push(msg);
2737 }
2738 InboundAction::None
2739 }
2740 _ => {
2741 let _ = events_tx.send(classify(&msg));
2744
2745 if let Some((_, events)) = pending.as_mut() {
2746 events.push(msg);
2747 } else {
2748 debug!("dropping inbound event with no pending turn");
2749 }
2750 InboundAction::None
2751 }
2752 }
2753}
2754
2755fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
2756 let request_id = msg.get("request_id").and_then(Value::as_str)?;
2757 let request = msg.get("request")?;
2758 let tool_name = request.get("tool_name").and_then(Value::as_str)?;
2759 let input = request.get("input").cloned().unwrap_or(Value::Null);
2760 Some(PermissionRequest {
2761 request_id: request_id.to_string(),
2762 tool_name: tool_name.to_string(),
2763 input,
2764 raw: request.clone(),
2765 })
2766}
2767
2768fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
2774 let response = msg.get("response")?;
2775 let request_id = response.get("request_id").and_then(Value::as_str)?;
2776 let outcome = match response.get("subtype").and_then(Value::as_str) {
2777 Some("success") => Ok(()),
2778 Some("error") => {
2779 let message = response
2780 .get("error")
2781 .and_then(Value::as_str)
2782 .unwrap_or("unknown control_response error")
2783 .to_string();
2784 Err(Error::DuplexControlFailed { message })
2785 }
2786 _ => return None,
2787 };
2788 Some((request_id.to_string(), outcome))
2789}
2790
2791async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
2792 let user_msg = serde_json::json!({
2793 "type": "user",
2794 "message": {
2795 "role": "user",
2796 "content": prompt,
2797 },
2798 "parent_tool_use_id": null,
2799 });
2800 write_line(stdin, &user_msg, "user message").await
2801}
2802
2803async fn write_control_request(
2804 stdin: &mut ChildStdin,
2805 request_id: &str,
2806 subtype: &str,
2807) -> Result<()> {
2808 let envelope = serde_json::json!({
2809 "type": "control_request",
2810 "request_id": request_id,
2811 "request": { "subtype": subtype },
2812 });
2813 write_line(stdin, &envelope, "control_request").await
2814}
2815
2816async fn write_permission_response(
2817 stdin: &mut ChildStdin,
2818 request_id: &str,
2819 decision: &PermissionDecision,
2820) -> Result<()> {
2821 let inner = match decision {
2822 PermissionDecision::Allow { updated_input } => {
2823 let mut obj = serde_json::Map::new();
2824 obj.insert("behavior".to_string(), Value::String("allow".to_string()));
2825 if let Some(input) = updated_input {
2826 obj.insert("updatedInput".to_string(), input.clone());
2827 }
2828 Value::Object(obj)
2829 }
2830 PermissionDecision::Deny { message } => serde_json::json!({
2831 "behavior": "deny",
2832 "message": message,
2833 }),
2834 PermissionDecision::Defer => {
2835 return Ok(());
2837 }
2838 };
2839 let envelope = serde_json::json!({
2840 "type": "control_response",
2841 "response": {
2842 "request_id": request_id,
2843 "subtype": "success",
2844 "response": inner,
2845 },
2846 });
2847 write_line(stdin, &envelope, "control_response").await
2848}
2849
2850async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
2851 let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
2852 message: format!("failed to serialize duplex {what}"),
2853 source: e,
2854 })?;
2855 line.push('\n');
2856 stdin
2857 .write_all(line.as_bytes())
2858 .await
2859 .map_err(|e| Error::Io {
2860 message: format!("failed to write {what} to duplex stdin"),
2861 source: e,
2862 working_dir: None,
2863 })?;
2864 stdin.flush().await.map_err(|e| Error::Io {
2865 message: "failed to flush duplex stdin".to_string(),
2866 source: e,
2867 working_dir: None,
2868 })?;
2869 Ok(())
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874 use super::*;
2875 use serde_json::json;
2876
2877 fn handle(
2878 msg: Value,
2879 pending: &mut PendingTurn,
2880 events_tx: &broadcast::Sender<InboundEvent>,
2881 ) -> InboundAction {
2882 handle_inbound(msg, pending, events_tx, None)
2883 }
2884
2885 #[test]
2886 fn build_args_default_includes_required_flags() {
2887 let args = DuplexOptions::default().build_args();
2888 assert!(args.contains(&"--print".to_string()));
2889 assert!(args.contains(&"--verbose".to_string()));
2890 assert!(
2891 args.windows(2)
2892 .any(|w| w == ["--output-format", "stream-json"])
2893 );
2894 assert!(
2895 args.windows(2)
2896 .any(|w| w == ["--input-format", "stream-json"])
2897 );
2898 }
2899
2900 #[test]
2901 fn build_args_includes_model() {
2902 let args = DuplexOptions::default().model("haiku").build_args();
2903 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2904 }
2905
2906 #[test]
2907 fn build_args_includes_system_prompts() {
2908 let args = DuplexOptions::default()
2909 .system_prompt("be concise")
2910 .append_system_prompt("also polite")
2911 .build_args();
2912 assert!(
2913 args.windows(2)
2914 .any(|w| w == ["--system-prompt", "be concise"])
2915 );
2916 assert!(
2917 args.windows(2)
2918 .any(|w| w == ["--append-system-prompt", "also polite"])
2919 );
2920 }
2921
2922 #[test]
2923 fn build_args_appends_raw_args_last() {
2924 let args = DuplexOptions::default()
2925 .arg("--add-dir")
2926 .arg("/tmp/foo")
2927 .build_args();
2928 assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
2930 }
2931
2932 fn preview_claude() -> Claude {
2935 Claude::builder()
2936 .binary("/usr/local/bin/claude")
2937 .build()
2938 .unwrap()
2939 }
2940
2941 #[test]
2942 fn spawn_command_args_prepends_global_args() {
2943 let claude = Claude::builder()
2944 .binary("/usr/local/bin/claude")
2945 .arg("--debug")
2946 .build()
2947 .unwrap();
2948 let args = DuplexOptions::default()
2949 .model("haiku")
2950 .spawn_command_args(&claude);
2951 assert_eq!(args[0], "--debug");
2952 assert_eq!(args[1], "--print");
2953 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2954 }
2955
2956 #[test]
2957 fn to_command_string_is_binary_plus_spawn_args() {
2958 let claude = Claude::builder()
2961 .binary("/usr/local/bin/claude")
2962 .arg("--debug")
2963 .build()
2964 .unwrap();
2965 let opts = DuplexOptions::default().agent("reviewer");
2966 let expected = format!(
2967 "/usr/local/bin/claude {}",
2968 opts.spawn_command_args(&claude).join(" ")
2969 );
2970 assert_eq!(opts.to_command_string(&claude), expected);
2971 }
2972
2973 #[test]
2974 fn to_command_string_includes_persona_flags() {
2975 let command_str = DuplexOptions::default()
2978 .agent("reviewer")
2979 .allowed_tool("Read")
2980 .allowed_tool("Bash(git:*)")
2981 .setting_sources("project")
2982 .to_command_string(&preview_claude());
2983 assert!(command_str.starts_with("/usr/local/bin/claude"));
2984 assert!(command_str.contains("--agent reviewer"));
2985 assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
2986 assert!(command_str.contains("--setting-sources project"));
2987 }
2988
2989 #[test]
2990 fn to_command_string_quotes_args_with_spaces() {
2991 let command_str = DuplexOptions::default()
2992 .system_prompt("be concise")
2993 .to_command_string(&preview_claude());
2994 assert!(command_str.contains("--system-prompt 'be concise'"));
2995 }
2996
2997 #[test]
2998 fn to_command_string_does_not_consume_options() {
2999 let claude = preview_claude();
3002 let opts = DuplexOptions::default().model("haiku");
3003 let first = opts.to_command_string(&claude);
3004 let second = opts.to_command_string(&claude);
3005 assert_eq!(first, second);
3006 }
3007
3008 #[test]
3009 fn build_args_includes_resume_when_set() {
3010 let args = DuplexOptions::default().resume("abc-123").build_args();
3011 assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
3012 }
3013
3014 #[test]
3015 fn build_args_omits_resume_by_default() {
3016 let args = DuplexOptions::default().build_args();
3017 assert!(
3018 !args.iter().any(|a| a == "--resume"),
3019 "--resume should not appear without an explicit resume(...) call; got {args:?}"
3020 );
3021 }
3022
3023 #[test]
3024 fn build_args_includes_continue_when_set() {
3025 let args = DuplexOptions::default().continue_session().build_args();
3026 assert!(args.iter().any(|a| a == "--continue"));
3027 }
3028
3029 #[test]
3030 fn build_args_omits_continue_by_default() {
3031 let args = DuplexOptions::default().build_args();
3032 assert!(!args.iter().any(|a| a == "--continue"));
3033 }
3034
3035 #[test]
3036 fn build_args_includes_worktree_flag_without_name() {
3037 let args = DuplexOptions::default().worktree(None::<&str>).build_args();
3038 assert!(args.iter().any(|a| a == "--worktree"));
3039 let pos = args.iter().position(|a| a == "--worktree").unwrap();
3041 assert!(
3042 args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
3043 "--worktree without a name should not be followed by a positional; got {args:?}"
3044 );
3045 }
3046
3047 #[test]
3048 fn build_args_includes_worktree_flag_with_name() {
3049 let args = DuplexOptions::default()
3050 .worktree(Some("agent-xyz"))
3051 .build_args();
3052 let pos = args.iter().position(|a| a == "--worktree").unwrap();
3053 assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
3054 }
3055
3056 #[test]
3057 fn build_args_omits_worktree_by_default() {
3058 let args = DuplexOptions::default().build_args();
3059 assert!(
3060 !args.iter().any(|a| a == "--worktree"),
3061 "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
3062 );
3063 }
3064
3065 #[test]
3066 fn worktree_lands_before_additional_args() {
3067 let args = DuplexOptions::default()
3069 .worktree(Some("foo"))
3070 .arg("--")
3071 .arg("trailing")
3072 .build_args();
3073 let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
3074 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3075 assert!(
3076 wt_pos < dash_dash_pos,
3077 "--worktree must precede `--` separator; got {args:?}"
3078 );
3079 }
3080
3081 #[test]
3082 fn build_args_includes_agent_when_set() {
3083 let args = DuplexOptions::default().agent("rust-qa").build_args();
3084 assert!(
3085 args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
3086 "missing --agent rust-qa in {args:?}"
3087 );
3088 }
3089
3090 #[test]
3091 fn build_args_omits_agent_by_default() {
3092 let args = DuplexOptions::default().build_args();
3093 assert!(
3094 !args.iter().any(|a| a == "--agent"),
3095 "--agent should not appear without an explicit agent(...) call; got {args:?}"
3096 );
3097 }
3098
3099 #[test]
3100 fn build_args_includes_agents_json_when_set() {
3101 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
3102 let args = DuplexOptions::default().agents_json(json).build_args();
3103 let pos = args.iter().position(|a| a == "--agents").unwrap();
3104 assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
3105 }
3106
3107 #[test]
3108 fn build_args_omits_agents_json_by_default() {
3109 let args = DuplexOptions::default().build_args();
3110 assert!(!args.iter().any(|a| a == "--agents"));
3111 }
3112
3113 #[test]
3114 fn agent_and_agents_json_compose() {
3115 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
3116 let args = DuplexOptions::default()
3117 .agents_json(json)
3118 .agent("reviewer")
3119 .build_args();
3120 assert!(args.iter().any(|a| a == "--agents"));
3122 assert!(args.iter().any(|a| a == "--agent"));
3123 }
3124
3125 #[test]
3126 fn agent_lands_before_additional_args() {
3127 let args = DuplexOptions::default()
3128 .agent("rust-qa")
3129 .arg("--")
3130 .arg("trailing")
3131 .build_args();
3132 let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
3133 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3134 assert!(
3135 agent_pos < dash_dash_pos,
3136 "--agent must precede `--` separator; got {args:?}"
3137 );
3138 }
3139
3140 #[test]
3141 fn agents_json_lands_before_additional_args() {
3142 let args = DuplexOptions::default()
3143 .agents_json("{}")
3144 .arg("--")
3145 .arg("trailing")
3146 .build_args();
3147 let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
3148 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3149 assert!(
3150 agents_pos < dash_dash_pos,
3151 "--agents must precede `--` separator; got {args:?}"
3152 );
3153 }
3154
3155 #[test]
3158 fn build_args_includes_session_id() {
3159 let args = DuplexOptions::default().session_id("sid-9").build_args();
3160 assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
3161 }
3162
3163 #[test]
3164 fn build_args_includes_setting_sources() {
3165 let args = DuplexOptions::default()
3166 .setting_sources("user,project")
3167 .build_args();
3168 assert!(
3169 args.windows(2)
3170 .any(|w| w == ["--setting-sources", "user,project"]),
3171 "got {args:?}"
3172 );
3173 }
3174
3175 #[test]
3176 fn build_args_omits_setting_sources_by_default() {
3177 let args = DuplexOptions::default().build_args();
3178 assert!(!args.iter().any(|a| a == "--setting-sources"));
3179 }
3180
3181 #[test]
3182 fn build_args_hermetic_emits_full_seal() {
3183 let args = DuplexOptions::default().hermetic().build_args();
3184 assert!(
3185 args.windows(2)
3186 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
3187 "got {args:?}"
3188 );
3189 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
3190 assert!(
3191 args.iter()
3192 .any(|a| a == "--exclude-dynamic-system-prompt-sections")
3193 );
3194 assert!(!args.iter().any(|a| a == "--bare"));
3196 }
3197
3198 #[test]
3199 fn build_args_hermetic_scoped_project_keeps_user() {
3200 let args = DuplexOptions::default()
3201 .hermetic_scoped(HermeticScope::Project)
3202 .build_args();
3203 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
3204 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
3205 }
3206
3207 #[test]
3208 fn build_args_includes_json_schema() {
3209 let schema = r#"{"type":"object"}"#;
3210 let args = DuplexOptions::default().json_schema(schema).build_args();
3211 assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
3212 }
3213
3214 #[test]
3215 fn build_args_joins_allowed_tools_comma_separated() {
3216 let args = DuplexOptions::default()
3217 .allowed_tools(["Read", "Bash(git log:*)"])
3218 .allowed_tool("Write")
3219 .build_args();
3220 assert!(
3221 args.windows(2)
3222 .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
3223 "missing joined --allowed-tools in {args:?}"
3224 );
3225 }
3226
3227 #[test]
3228 fn build_args_joins_disallowed_tools_comma_separated() {
3229 let args = DuplexOptions::default()
3230 .disallowed_tools(["WebSearch"])
3231 .disallowed_tool("WebFetch")
3232 .build_args();
3233 assert!(
3234 args.windows(2)
3235 .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
3236 "missing joined --disallowed-tools in {args:?}"
3237 );
3238 }
3239
3240 #[test]
3241 fn build_args_includes_caps() {
3242 let args = DuplexOptions::default()
3243 .max_turns(4)
3244 .max_budget_usd(0.25)
3245 .build_args();
3246 assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
3247 assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
3248 }
3249
3250 #[test]
3251 fn build_args_includes_fallback_model_and_effort() {
3252 let args = DuplexOptions::default()
3253 .fallback_model("haiku")
3254 .effort(Effort::Low)
3255 .build_args();
3256 assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
3257 assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
3258 }
3259
3260 #[test]
3261 fn build_args_repeats_add_dir_and_mcp_config() {
3262 let args = DuplexOptions::default()
3263 .add_dir("/a")
3264 .add_dir("/b")
3265 .mcp_config("x.json")
3266 .strict_mcp_config()
3267 .build_args();
3268 assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
3269 assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
3270 assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
3271 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
3272 }
3273
3274 #[test]
3275 fn build_args_includes_no_session_persistence() {
3276 let args = DuplexOptions::default()
3277 .no_session_persistence()
3278 .build_args();
3279 assert!(args.iter().any(|a| a == "--no-session-persistence"));
3280 }
3281
3282 #[test]
3285 fn build_args_joins_tools_comma_separated() {
3286 let args = DuplexOptions::default()
3287 .tools(["Bash", "Read", "Edit"])
3288 .build_args();
3289 assert!(
3290 args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
3291 "missing joined --tools in {args:?}"
3292 );
3293 }
3294
3295 #[test]
3296 fn build_args_repeats_file_per_spec() {
3297 let args = DuplexOptions::default()
3298 .file("file_a:doc.txt")
3299 .file("file_b:notes.md")
3300 .build_args();
3301 assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
3302 assert!(args.iter().any(|a| a == "file_a:doc.txt"));
3303 assert!(args.iter().any(|a| a == "file_b:notes.md"));
3304 }
3305
3306 #[test]
3307 fn build_args_includes_settings() {
3308 let args = DuplexOptions::default()
3309 .settings("/tmp/settings.json")
3310 .build_args();
3311 assert!(
3312 args.windows(2)
3313 .any(|w| w == ["--settings", "/tmp/settings.json"])
3314 );
3315 }
3316
3317 #[test]
3318 fn build_args_includes_fork_session() {
3319 let args = DuplexOptions::default().fork_session().build_args();
3320 assert!(args.iter().any(|a| a == "--fork-session"));
3321 }
3322
3323 #[test]
3324 fn build_args_includes_debug_filter_and_file() {
3325 let args = DuplexOptions::default()
3326 .debug_filter("api,hooks")
3327 .debug_file("/tmp/debug.log")
3328 .build_args();
3329 assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
3330 assert!(
3331 args.windows(2)
3332 .any(|w| w == ["--debug-file", "/tmp/debug.log"])
3333 );
3334 }
3335
3336 #[test]
3337 fn build_args_includes_betas() {
3338 let args = DuplexOptions::default().betas("feature-x").build_args();
3339 assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
3340 }
3341
3342 #[test]
3343 fn build_args_repeats_plugin_dir_and_url() {
3344 let args = DuplexOptions::default()
3345 .plugin_dir("/plugins/a")
3346 .plugin_dir("/plugins/b")
3347 .plugin_url("https://example.com/p.zip")
3348 .build_args();
3349 assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
3350 assert!(
3351 args.windows(2)
3352 .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
3353 );
3354 }
3355
3356 #[test]
3357 fn build_args_includes_bare_family_bool_flags() {
3358 let args = DuplexOptions::default()
3359 .tmux()
3360 .bare()
3361 .safe_mode()
3362 .disable_slash_commands()
3363 .include_hook_events()
3364 .exclude_dynamic_system_prompt_sections()
3365 .build_args();
3366 for flag in [
3367 "--tmux",
3368 "--bare",
3369 "--safe-mode",
3370 "--disable-slash-commands",
3371 "--include-hook-events",
3372 "--exclude-dynamic-system-prompt-sections",
3373 ] {
3374 assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
3375 }
3376 }
3377
3378 #[test]
3379 fn build_args_includes_name() {
3380 let args = DuplexOptions::default().name("my session").build_args();
3381 assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
3382 }
3383
3384 #[test]
3385 fn build_args_omits_promoted_parity_flags_by_default() {
3386 let args = DuplexOptions::default().build_args();
3387 for flag in [
3388 "--tools",
3389 "--file",
3390 "--settings",
3391 "--fork-session",
3392 "--debug",
3393 "--debug-file",
3394 "--betas",
3395 "--plugin-dir",
3396 "--plugin-url",
3397 "--tmux",
3398 "--bare",
3399 "--safe-mode",
3400 "--disable-slash-commands",
3401 "--include-hook-events",
3402 "--exclude-dynamic-system-prompt-sections",
3403 "--name",
3404 ] {
3405 assert!(
3406 !args.iter().any(|a| a == flag),
3407 "{flag} should be absent by default; got {args:?}"
3408 );
3409 }
3410 }
3411
3412 #[test]
3413 fn parity_flags_land_before_additional_args() {
3414 let args = DuplexOptions::default()
3416 .max_turns(2)
3417 .json_schema("{}")
3418 .arg("--")
3419 .arg("trailing")
3420 .build_args();
3421 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3422 for flag in ["--max-turns", "--json-schema"] {
3423 let pos = args.iter().position(|a| a == flag).unwrap();
3424 assert!(
3425 pos < dash_dash_pos,
3426 "{flag} must precede `--` separator; got {args:?}"
3427 );
3428 }
3429 }
3430
3431 #[test]
3432 fn build_args_omits_parity_flags_by_default() {
3433 let args = DuplexOptions::default().build_args();
3434 for flag in [
3435 "--session-id",
3436 "--json-schema",
3437 "--allowed-tools",
3438 "--disallowed-tools",
3439 "--max-turns",
3440 "--max-budget-usd",
3441 "--fallback-model",
3442 "--effort",
3443 "--add-dir",
3444 "--mcp-config",
3445 "--strict-mcp-config",
3446 "--no-session-persistence",
3447 ] {
3448 assert!(
3449 !args.iter().any(|a| a == flag),
3450 "{flag} should not appear by default; got {args:?}"
3451 );
3452 }
3453 }
3454
3455 #[test]
3456 fn resume_lands_before_additional_args() {
3457 let args = DuplexOptions::default()
3462 .resume("xyz")
3463 .arg("--")
3464 .arg("trailing")
3465 .build_args();
3466 let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
3467 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
3468 assert!(
3469 resume_pos < dash_dash_pos,
3470 "--resume must precede `--` separator; got {args:?}"
3471 );
3472 }
3473
3474 #[test]
3475 fn turn_result_accessors_pull_from_result() {
3476 let r = TurnResult {
3477 result: json!({
3478 "type": "result",
3479 "result": "hello",
3480 "session_id": "sess-123",
3481 "total_cost_usd": 0.0042,
3482 "duration_ms": 1234_u64,
3483 }),
3484 events: vec![],
3485 };
3486 assert_eq!(r.result_text(), Some("hello"));
3487 assert_eq!(r.session_id(), Some("sess-123"));
3488 assert_eq!(r.total_cost_usd(), Some(0.0042));
3489 assert_eq!(r.duration_ms(), Some(1234));
3490 }
3491
3492 #[test]
3493 fn turn_result_total_cost_falls_back_to_legacy_field() {
3494 let r = TurnResult {
3495 result: json!({ "cost_usd": 0.5 }),
3496 events: vec![],
3497 };
3498 assert_eq!(r.total_cost_usd(), Some(0.5));
3499 }
3500
3501 #[test]
3502 fn turn_result_accessors_return_none_when_missing() {
3503 let r = TurnResult {
3504 result: json!({}),
3505 events: vec![],
3506 };
3507 assert_eq!(r.result_text(), None);
3508 assert_eq!(r.session_id(), None);
3509 assert_eq!(r.total_cost_usd(), None);
3510 assert_eq!(r.duration_ms(), None);
3511 assert_eq!(r.subtype(), None);
3512 assert!(!r.is_error());
3513 }
3514
3515 #[test]
3516 fn turn_result_subtype_and_is_error_for_interrupted_turn() {
3517 let r = TurnResult {
3518 result: json!({
3519 "type": "result",
3520 "subtype": "error_during_execution",
3521 "is_error": true,
3522 "total_cost_usd": 0.0004,
3523 }),
3524 events: vec![],
3525 };
3526 assert_eq!(r.subtype(), Some("error_during_execution"));
3527 assert!(r.is_error());
3528 assert_eq!(r.result_text(), None);
3529 }
3530
3531 #[test]
3532 fn turn_cost_delta_tracks_previous_total() {
3533 let mut prev = None;
3534 assert_eq!(turn_cost_delta(&mut prev, Some(0.25)), Some(0.25));
3535 assert_eq!(turn_cost_delta(&mut prev, Some(0.75)), Some(0.5));
3536 assert_eq!(turn_cost_delta(&mut prev, None), None);
3538 assert_eq!(prev, Some(0.75));
3539 assert_eq!(turn_cost_delta(&mut prev, Some(0.5)), Some(0.0));
3541 assert_eq!(turn_cost_delta(&mut prev, Some(1.0)), Some(0.5));
3542 }
3543
3544 #[test]
3545 fn handle_inbound_appends_non_result_to_pending_events() {
3546 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3547 let (events_tx, _events_rx) = broadcast::channel(16);
3548 let mut pending = Some((tx, Vec::new()));
3549 handle(
3550 json!({ "type": "assistant", "message": {} }),
3551 &mut pending,
3552 &events_tx,
3553 );
3554 let (_, events) = pending.as_ref().unwrap();
3555 assert_eq!(events.len(), 1);
3556 assert_eq!(
3557 events[0].get("type").and_then(Value::as_str),
3558 Some("assistant")
3559 );
3560 }
3561
3562 #[test]
3563 fn handle_inbound_resolves_pending_on_result() {
3564 let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
3565 let (events_tx, _events_rx) = broadcast::channel(16);
3566 let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
3567 handle(
3568 json!({ "type": "result", "result": "ok" }),
3569 &mut pending,
3570 &events_tx,
3571 );
3572 assert!(pending.is_none());
3573 let received = rx.blocking_recv().unwrap().unwrap();
3574 assert_eq!(received.result_text(), Some("ok"));
3575 assert_eq!(received.events.len(), 1);
3576 }
3577
3578 #[test]
3579 fn handle_inbound_drops_orphans_without_pending_turn() {
3580 let (events_tx, _events_rx) = broadcast::channel(16);
3581 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
3582 handle(json!({ "type": "assistant" }), &mut pending, &events_tx);
3583 handle(
3584 json!({ "type": "result", "result": "ok" }),
3585 &mut pending,
3586 &events_tx,
3587 );
3588 assert!(pending.is_none());
3589 }
3590
3591 #[test]
3592 fn handle_inbound_broadcasts_classified_event() {
3593 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3594 let (events_tx, mut events_rx) = broadcast::channel(16);
3595 let mut pending = Some((tx, Vec::new()));
3596 handle(
3597 json!({ "type": "assistant", "message": { "role": "assistant" } }),
3598 &mut pending,
3599 &events_tx,
3600 );
3601 let event = events_rx.try_recv().expect("classified event broadcast");
3602 assert!(matches!(event, InboundEvent::Assistant(_)));
3603 }
3604
3605 #[test]
3606 fn handle_inbound_does_not_broadcast_result() {
3607 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3608 let (events_tx, mut events_rx) = broadcast::channel(16);
3609 let mut pending = Some((tx, Vec::new()));
3610 handle(
3611 json!({ "type": "result", "result": "ok" }),
3612 &mut pending,
3613 &events_tx,
3614 );
3615 assert!(events_rx.try_recv().is_err());
3617 }
3618
3619 #[test]
3620 fn classify_system_init_pulls_session_id() {
3621 let v = json!({
3622 "type": "system",
3623 "subtype": "init",
3624 "session_id": "sess-abc",
3625 });
3626 match classify(&v) {
3627 InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
3628 other => panic!("expected SystemInit, got {other:?}"),
3629 }
3630 }
3631
3632 #[test]
3633 fn classify_system_without_init_subtype_is_other() {
3634 let v = json!({ "type": "system", "subtype": "compaction" });
3635 assert!(matches!(classify(&v), InboundEvent::Other(_)));
3636 }
3637
3638 #[test]
3639 fn classify_system_init_without_session_id_is_other() {
3640 let v = json!({ "type": "system", "subtype": "init" });
3641 assert!(matches!(classify(&v), InboundEvent::Other(_)));
3642 }
3643
3644 #[test]
3645 fn classify_assistant_stream_event_user() {
3646 assert!(matches!(
3647 classify(&json!({ "type": "assistant" })),
3648 InboundEvent::Assistant(_)
3649 ));
3650 assert!(matches!(
3651 classify(&json!({ "type": "stream_event" })),
3652 InboundEvent::StreamEvent(_)
3653 ));
3654 assert!(matches!(
3655 classify(&json!({ "type": "user" })),
3656 InboundEvent::User(_)
3657 ));
3658 }
3659
3660 #[test]
3661 fn classify_unknown_type_is_other() {
3662 assert!(matches!(
3663 classify(&json!({ "type": "control_request" })),
3664 InboundEvent::Other(_)
3665 ));
3666 assert!(matches!(
3667 classify(&json!({ "type": "future_thing" })),
3668 InboundEvent::Other(_)
3669 ));
3670 assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
3671 }
3672
3673 #[test]
3674 fn build_args_does_not_emit_subscriber_capacity_flag() {
3675 let args = DuplexOptions::default()
3677 .subscriber_capacity(64)
3678 .build_args();
3679 assert!(!args.iter().any(|a| a.contains("subscriber")));
3680 assert!(!args.iter().any(|a| a.contains("capacity")));
3681 }
3682
3683 #[test]
3684 fn build_args_includes_permission_prompt_tool_when_handler_set() {
3685 let handler = PermissionHandler::new(|_req| async move {
3686 PermissionDecision::Allow {
3687 updated_input: None,
3688 }
3689 });
3690 let args = DuplexOptions::default().on_permission(handler).build_args();
3691 assert!(
3692 args.windows(2)
3693 .any(|w| w == ["--permission-prompt-tool", "stdio"])
3694 );
3695 }
3696
3697 #[test]
3698 fn build_args_omits_permission_prompt_tool_without_handler() {
3699 let args = DuplexOptions::default().build_args();
3700 assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
3701 }
3702
3703 #[test]
3704 fn build_args_emits_permission_mode_flag() {
3705 let args = DuplexOptions::default()
3706 .permission_mode(PermissionMode::AcceptEdits)
3707 .build_args();
3708 assert!(
3709 args.windows(2)
3710 .any(|w| w == ["--permission-mode", "acceptEdits"]),
3711 "missing --permission-mode acceptEdits in {args:?}"
3712 );
3713 }
3714
3715 #[test]
3716 fn build_args_emits_plan_mode() {
3717 let args = DuplexOptions::default()
3718 .permission_mode(PermissionMode::Plan)
3719 .build_args();
3720 assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
3721 }
3722
3723 #[test]
3724 fn build_args_omits_permission_mode_by_default() {
3725 let args = DuplexOptions::default().build_args();
3726 assert!(!args.iter().any(|a| a == "--permission-mode"));
3727 }
3728
3729 #[test]
3730 fn build_args_emits_dangerously_skip_permissions_flag() {
3731 let args = DuplexOptions::default()
3732 .dangerously_skip_permissions()
3733 .build_args();
3734 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
3735 }
3736
3737 #[test]
3738 fn build_args_omits_dangerously_skip_by_default() {
3739 let args = DuplexOptions::default().build_args();
3740 assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
3741 }
3742
3743 #[test]
3744 fn parse_permission_request_extracts_fields() {
3745 let msg = json!({
3746 "type": "control_request",
3747 "request_id": "req-1",
3748 "request": {
3749 "subtype": "can_use_tool",
3750 "tool_name": "Bash",
3751 "input": { "command": "ls" }
3752 }
3753 });
3754 let req = parse_permission_request(&msg).expect("permission request");
3755 assert_eq!(req.request_id, "req-1");
3756 assert_eq!(req.tool_name, "Bash");
3757 assert_eq!(req.input, json!({ "command": "ls" }));
3758 assert_eq!(
3759 req.raw.get("subtype").and_then(Value::as_str),
3760 Some("can_use_tool")
3761 );
3762 }
3763
3764 #[test]
3765 fn parse_permission_request_returns_none_when_missing_request_id() {
3766 let msg = json!({
3767 "type": "control_request",
3768 "request": {
3769 "subtype": "can_use_tool",
3770 "tool_name": "Bash",
3771 }
3772 });
3773 assert!(parse_permission_request(&msg).is_none());
3774 }
3775
3776 #[test]
3777 fn parse_permission_request_returns_none_when_missing_tool_name() {
3778 let msg = json!({
3779 "type": "control_request",
3780 "request_id": "req-1",
3781 "request": { "subtype": "can_use_tool" }
3782 });
3783 assert!(parse_permission_request(&msg).is_none());
3784 }
3785
3786 #[test]
3787 fn parse_permission_request_handles_missing_input() {
3788 let msg = json!({
3789 "type": "control_request",
3790 "request_id": "req-1",
3791 "request": {
3792 "subtype": "can_use_tool",
3793 "tool_name": "Bash",
3794 }
3795 });
3796 let req = parse_permission_request(&msg).expect("request");
3797 assert_eq!(req.input, Value::Null);
3798 }
3799
3800 #[test]
3801 fn handle_inbound_returns_permission_for_can_use_tool() {
3802 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3803 let (events_tx, _events_rx) = broadcast::channel(16);
3804 let mut pending = Some((tx, Vec::new()));
3805 let action = handle(
3806 json!({
3807 "type": "control_request",
3808 "request_id": "req-1",
3809 "request": {
3810 "subtype": "can_use_tool",
3811 "tool_name": "Bash",
3812 "input": { "command": "ls" }
3813 }
3814 }),
3815 &mut pending,
3816 &events_tx,
3817 );
3818 match action {
3819 InboundAction::Permission(req) => {
3820 assert_eq!(req.request_id, "req-1");
3821 assert_eq!(req.tool_name, "Bash");
3822 }
3823 InboundAction::None
3824 | InboundAction::ControlResponse { .. }
3825 | InboundAction::EventLimit(_) => {
3826 panic!("expected Permission action");
3827 }
3828 }
3829 let (_, events) = pending.as_ref().unwrap();
3831 assert_eq!(events.len(), 1);
3832 }
3833
3834 #[test]
3835 fn handle_inbound_treats_unknown_control_request_as_other() {
3836 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3837 let (events_tx, mut events_rx) = broadcast::channel(16);
3838 let mut pending = Some((tx, Vec::new()));
3839 let action = handle(
3840 json!({
3841 "type": "control_request",
3842 "request_id": "req-2",
3843 "request": { "subtype": "future_subtype" }
3844 }),
3845 &mut pending,
3846 &events_tx,
3847 );
3848 assert!(matches!(action, InboundAction::None));
3849 let event = events_rx.try_recv().expect("broadcast");
3850 assert!(matches!(event, InboundEvent::Other(_)));
3851 }
3852
3853 #[tokio::test]
3854 async fn permission_handler_invokes_closure_async() {
3855 let handler = PermissionHandler::new(|req| async move {
3856 if req.tool_name == "Bash" {
3857 PermissionDecision::Deny {
3858 message: "no bash".into(),
3859 }
3860 } else {
3861 PermissionDecision::Allow {
3862 updated_input: None,
3863 }
3864 }
3865 });
3866 let req = PermissionRequest {
3867 request_id: "r1".into(),
3868 tool_name: "Bash".into(),
3869 input: Value::Null,
3870 raw: Value::Null,
3871 };
3872 match handler.invoke(req).await {
3873 PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
3874 other => panic!("expected Deny, got {other:?}"),
3875 }
3876 }
3877
3878 #[test]
3879 fn parse_control_response_extracts_success() {
3880 let msg = json!({
3881 "type": "control_response",
3882 "response": {
3883 "request_id": "interrupt-1",
3884 "subtype": "success",
3885 "response": {}
3886 }
3887 });
3888 let (id, outcome) = parse_control_response(&msg).expect("parsed");
3889 assert_eq!(id, "interrupt-1");
3890 assert!(outcome.is_ok());
3891 }
3892
3893 #[test]
3894 fn parse_control_response_extracts_error_with_message() {
3895 let msg = json!({
3896 "type": "control_response",
3897 "response": {
3898 "request_id": "interrupt-2",
3899 "subtype": "error",
3900 "error": "no turn in flight"
3901 }
3902 });
3903 let (id, outcome) = parse_control_response(&msg).expect("parsed");
3904 assert_eq!(id, "interrupt-2");
3905 match outcome {
3906 Err(Error::DuplexControlFailed { message }) => {
3907 assert_eq!(message, "no turn in flight");
3908 }
3909 other => panic!("expected DuplexControlFailed, got {other:?}"),
3910 }
3911 }
3912
3913 #[test]
3914 fn parse_control_response_returns_none_on_missing_request_id() {
3915 let msg = json!({
3916 "type": "control_response",
3917 "response": { "subtype": "success" }
3918 });
3919 assert!(parse_control_response(&msg).is_none());
3920 }
3921
3922 #[test]
3923 fn parse_control_response_returns_none_on_unknown_subtype() {
3924 let msg = json!({
3925 "type": "control_response",
3926 "response": { "request_id": "x", "subtype": "future_subtype" }
3927 });
3928 assert!(parse_control_response(&msg).is_none());
3929 }
3930
3931 #[test]
3932 fn handle_inbound_returns_control_response_action() {
3933 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3934 let (events_tx, _events_rx) = broadcast::channel(16);
3935 let mut pending = Some((tx, Vec::new()));
3936 let action = handle(
3937 json!({
3938 "type": "control_response",
3939 "response": {
3940 "request_id": "interrupt-1",
3941 "subtype": "success",
3942 "response": {}
3943 }
3944 }),
3945 &mut pending,
3946 &events_tx,
3947 );
3948 match action {
3949 InboundAction::ControlResponse {
3950 request_id,
3951 outcome,
3952 } => {
3953 assert_eq!(request_id, "interrupt-1");
3954 assert!(outcome.is_ok());
3955 }
3956 InboundAction::None | InboundAction::Permission(_) | InboundAction::EventLimit(_) => {
3957 panic!("expected ControlResponse action");
3958 }
3959 }
3960 }
3961
3962 #[test]
3963 fn handle_inbound_treats_malformed_control_response_as_other() {
3964 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3965 let (events_tx, mut events_rx) = broadcast::channel(16);
3966 let mut pending = Some((tx, Vec::new()));
3967 let action = handle(
3968 json!({
3969 "type": "control_response",
3970 "response": { "subtype": "success" }
3971 }),
3972 &mut pending,
3973 &events_tx,
3974 );
3975 assert!(matches!(action, InboundAction::None));
3976 let event = events_rx.try_recv().expect("broadcast");
3977 assert!(matches!(event, InboundEvent::Other(_)));
3978 }
3979
3980 #[tokio::test]
3981 async fn permission_handler_clones_arc() {
3982 let handler = PermissionHandler::new(|_req| async move {
3983 PermissionDecision::Allow {
3984 updated_input: None,
3985 }
3986 });
3987 let cloned = handler.clone();
3988 let req = PermissionRequest {
3989 request_id: "r1".into(),
3990 tool_name: "Read".into(),
3991 input: Value::Null,
3992 raw: Value::Null,
3993 };
3994 let _ = handler.invoke(req.clone()).await;
3996 let _ = cloned.invoke(req).await;
3997 }
3998
3999 fn fake_session(
4006 initial: SessionExitStatus,
4007 ) -> (
4008 DuplexSession,
4009 watch::Sender<SessionExitStatus>,
4010 oneshot::Sender<()>,
4011 ) {
4012 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
4013 let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
4014 let (exit_tx, exit_rx) = watch::channel(initial);
4015 let (stop_tx, stop_rx) = oneshot::channel::<()>();
4016
4017 let join = tokio::spawn(async move {
4018 let _outbound_rx = outbound_rx;
4019 let _ = stop_rx.await;
4020 Ok::<(), Error>(())
4021 });
4022
4023 let session = DuplexSession {
4024 outbound_tx,
4025 events_tx,
4026 exit_rx,
4027 join,
4028 };
4029 (session, exit_tx, stop_tx)
4030 }
4031
4032 #[tokio::test]
4033 async fn is_alive_true_while_running() {
4034 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4035 assert!(session.is_alive());
4036 }
4037
4038 #[tokio::test]
4039 async fn is_alive_false_after_completed() {
4040 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4041 exit_tx.send(SessionExitStatus::Completed).unwrap();
4042 assert!(!session.is_alive());
4043 }
4044
4045 #[tokio::test]
4046 async fn is_alive_false_after_failed() {
4047 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4048 exit_tx
4049 .send(SessionExitStatus::Failed("boom".into()))
4050 .unwrap();
4051 assert!(!session.is_alive());
4052 }
4053
4054 #[tokio::test]
4055 async fn exit_status_reports_running_initially() {
4056 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4057 assert!(matches!(session.exit_status(), SessionExitStatus::Running));
4058 }
4059
4060 #[tokio::test]
4061 async fn exit_status_reflects_completed() {
4062 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4063 exit_tx.send(SessionExitStatus::Completed).unwrap();
4064 assert!(matches!(
4065 session.exit_status(),
4066 SessionExitStatus::Completed
4067 ));
4068 }
4069
4070 #[tokio::test]
4071 async fn exit_status_reflects_failed_with_message() {
4072 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4073 exit_tx
4074 .send(SessionExitStatus::Failed("oh no".into()))
4075 .unwrap();
4076 match session.exit_status() {
4077 SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
4078 other => panic!("expected Failed, got {other:?}"),
4079 }
4080 }
4081
4082 #[tokio::test]
4083 async fn wait_for_exit_returns_immediately_when_already_terminal() {
4084 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4085 exit_tx.send(SessionExitStatus::Completed).unwrap();
4086 let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
4087 .await
4088 .expect("wait_for_exit should not block when already terminal");
4089 assert!(matches!(status, SessionExitStatus::Completed));
4090 }
4091
4092 #[tokio::test]
4093 async fn wait_for_exit_blocks_until_state_transitions() {
4094 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4095
4096 let waiter = async { session.wait_for_exit().await };
4097 let driver = async {
4098 tokio::time::sleep(Duration::from_millis(20)).await;
4099 exit_tx.send(SessionExitStatus::Completed).unwrap();
4100 };
4101 let (status, ()) = tokio::join!(waiter, driver);
4102 assert!(matches!(status, SessionExitStatus::Completed));
4103 }
4104
4105 #[tokio::test]
4106 async fn wait_for_exit_supports_multiple_observers() {
4107 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4108
4109 let waiter1 = async { session.wait_for_exit().await };
4110 let waiter2 = async { session.wait_for_exit().await };
4111 let driver = async {
4112 tokio::time::sleep(Duration::from_millis(20)).await;
4113 exit_tx
4114 .send(SessionExitStatus::Failed("crash".into()))
4115 .unwrap();
4116 };
4117 let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
4118 match s1 {
4119 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
4120 other => panic!("waiter1 expected Failed, got {other:?}"),
4121 }
4122 match s2 {
4123 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
4124 other => panic!("waiter2 expected Failed, got {other:?}"),
4125 }
4126 }
4127
4128 #[tokio::test]
4129 async fn wait_for_exit_returns_last_value_when_sender_dropped() {
4130 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
4134 let waiter = async { session.wait_for_exit().await };
4135 let driver = async {
4136 tokio::time::sleep(Duration::from_millis(20)).await;
4137 drop(exit_tx);
4138 };
4139 let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
4140 tokio::join!(waiter, driver)
4141 })
4142 .await
4143 .expect("wait_for_exit must not hang when sender is dropped");
4144 assert!(matches!(status, SessionExitStatus::Running));
4145 }
4146
4147 #[tokio::test]
4148 async fn stderr_reader_retries_interruptions_and_retains_bytes_on_failure() {
4149 struct Reader(u8);
4150 impl tokio::io::AsyncRead for Reader {
4151 fn poll_read(
4152 mut self: std::pin::Pin<&mut Self>,
4153 _: &mut std::task::Context<'_>,
4154 buf: &mut tokio::io::ReadBuf<'_>,
4155 ) -> std::task::Poll<std::io::Result<()>> {
4156 self.0 += 1;
4157 std::task::Poll::Ready(match self.0 {
4158 1 => Err(std::io::ErrorKind::Interrupted.into()),
4159 2 => {
4160 buf.put_slice(b"retained");
4161 Ok(())
4162 }
4163 _ => Err(std::io::Error::other("stderr read fault")),
4164 })
4165 }
4166 }
4167 let mut tail = VecDeque::new();
4168 let error = read_stderr_tail(&mut Reader(0), &mut tail)
4169 .await
4170 .unwrap_err();
4171 assert_eq!(error.to_string(), "stderr read fault");
4172 assert_eq!(tail.into_iter().collect::<Vec<_>>(), b"retained");
4173 }
4174
4175 #[cfg(unix)]
4176 #[test]
4177 fn stderr_read_failure_keeps_native_exit_evidence_and_diagnostics() {
4178 for code in [0, 7] {
4179 let status = std::process::Command::new("/bin/sh")
4180 .args(["-c", &format!("exit {code}")])
4181 .status()
4182 .unwrap();
4183 let result = resident_outcome(
4184 Ok((status, false)),
4185 Some(Err(std::io::Error::other("stderr read fault"))),
4186 "retained diagnostics".into(),
4187 );
4188 match result {
4189 Err(Error::DuplexProcessFailed {
4190 status,
4191 stderr,
4192 stderr_read_error,
4193 ..
4194 }) => {
4195 assert_eq!(status.code(), Some(7));
4196 assert_eq!(stderr, "retained diagnostics");
4197 assert_eq!(stderr_read_error.unwrap().to_string(), "stderr read fault");
4198 }
4199 Err(Error::Io {
4200 message, source, ..
4201 }) if code == 0 => {
4202 assert!(message.contains("retained diagnostics"));
4203 assert_eq!(source.to_string(), "stderr read fault");
4204 }
4205 other => panic!("expected retained exit/read evidence, got {other:?}"),
4206 }
4207 }
4208 }
4209 #[test]
4210 fn compatible_headless_flags_are_explicit_and_do_not_activate_bypass() {
4211 let defaults = DuplexOptions::default().build_args();
4212 assert!(
4213 !defaults.iter().any(|arg| arg == "--forward-subagent-text"
4214 || arg == "--allow-dangerously-skip-permissions")
4215 );
4216 let args = DuplexOptions::default()
4217 .forward_subagent_text()
4218 .allow_dangerously_skip_permissions()
4219 .build_args();
4220 assert!(args.iter().any(|arg| arg == "--forward-subagent-text"));
4221 assert!(
4222 args.iter()
4223 .any(|arg| arg == "--allow-dangerously-skip-permissions")
4224 );
4225 assert!(
4226 !args
4227 .iter()
4228 .any(|arg| arg == "--dangerously-skip-permissions" || arg == "bypassPermissions")
4229 );
4230 }
4231
4232 #[test]
4235 fn partial_messages_are_opt_in() {
4236 let defaults = DuplexOptions::default().build_args();
4237 assert!(!defaults.iter().any(|a| a == "--include-partial-messages"));
4238 let args = DuplexOptions::default()
4239 .include_partial_messages()
4240 .arg("--")
4241 .build_args();
4242 let flag = args
4243 .iter()
4244 .position(|a| a == "--include-partial-messages")
4245 .expect("opt-in emits the flag");
4246 let dash_dash = args.iter().position(|a| a == "--").unwrap();
4247 assert!(flag < dash_dash, "{args:?}");
4248 }
4249
4250 #[test]
4251 fn policies_are_unlimited_by_default_and_emit_no_flags() {
4252 let defaults = DuplexOptions::default();
4253 assert!(defaults.policy.max_frame_bytes.is_none());
4254 assert!(defaults.policy.max_turn_bytes.is_none());
4255 assert!(defaults.policy.max_turn_events.is_none());
4256 assert!(defaults.policy.turn_timeout.is_none());
4257 assert!(defaults.policy.control_timeout.is_none());
4258 let configured = DuplexOptions::default()
4259 .max_frame_bytes(1)
4260 .max_turn_bytes(2)
4261 .max_turn_events(3)
4262 .turn_timeout(Duration::from_secs(4))
4263 .control_timeout(Duration::from_secs(5));
4264 assert_eq!(configured.build_args(), defaults.build_args());
4265 }
4266
4267 #[test]
4268 fn oversized_deadline_is_treated_as_unbounded() {
4269 assert!(OpDeadline::start(DuplexOperation::Turn, Some(Duration::MAX)).is_none());
4270 assert!(OpDeadline::start(DuplexOperation::Turn, None).is_none());
4271 }
4272
4273 async fn frames_of(
4274 input: &[u8],
4275 max_frame: Option<usize>,
4276 turn: &mut TurnBytes,
4277 ) -> (Vec<Vec<u8>>, Option<PolicyFailure>) {
4278 let mut reader = FrameReader::new(input, max_frame);
4279 let mut frames = Vec::new();
4280 loop {
4281 match reader.next_frame(turn).await {
4282 Ok(FrameRead::Frame(frame)) => frames.push(frame),
4283 Ok(FrameRead::Eof) => return (frames, None),
4284 Err(FrameError::Limit(failure)) => return (frames, Some(failure)),
4285 Err(FrameError::Io(error)) => panic!("unexpected io error: {error}"),
4286 }
4287 }
4288 }
4289
4290 #[tokio::test]
4291 async fn frame_reader_strips_terminators_and_keeps_a_final_partial_line() {
4292 let mut turn = TurnBytes::default();
4293 let (frames, failure) = frames_of(b"a\r\n\nb\nc", None, &mut turn).await;
4294 assert!(failure.is_none());
4295 assert_eq!(
4296 frames,
4297 vec![b"a".to_vec(), Vec::new(), b"b".to_vec(), b"c".to_vec()]
4298 );
4299 }
4300
4301 #[tokio::test]
4302 async fn frame_limit_trips_before_an_unterminated_line_is_extended() {
4303 let input = vec![b'x'; 1 << 20];
4304 let mut reader = FrameReader::new(&input[..], Some(1024));
4305 let mut turn = TurnBytes::default();
4306 match reader.next_frame(&mut turn).await {
4307 Err(FrameError::Limit(PolicyFailure::Limit { limit, max })) => {
4308 assert_eq!(limit, DuplexLimit::FrameBytes);
4309 assert_eq!(max, 1024);
4310 }
4311 _ => panic!("expected a frame limit failure"),
4312 }
4313 assert!(reader.partial.len() <= 1024, "{}", reader.partial.len());
4314 assert!(
4315 reader.partial.capacity() <= 2048,
4316 "{}",
4317 reader.partial.capacity()
4318 );
4319 }
4320
4321 #[tokio::test]
4322 async fn frame_limit_counts_the_newline() {
4323 let mut turn = TurnBytes::default();
4324 let (frames, failure) = frames_of(b"abc\n", Some(4), &mut turn).await;
4325 assert_eq!(frames, vec![b"abc".to_vec()]);
4326 assert!(failure.is_none());
4327 let (_, failure) = frames_of(b"abcd\n", Some(4), &mut turn).await;
4328 assert!(matches!(
4329 failure,
4330 Some(PolicyFailure::Limit {
4331 limit: DuplexLimit::FrameBytes,
4332 ..
4333 })
4334 ));
4335 }
4336
4337 #[tokio::test]
4338 async fn turn_bytes_count_newlines_blank_and_malformed_lines_only_while_active() {
4339 let mut idle = TurnBytes {
4340 limit: Some(3),
4341 used: None,
4342 };
4343 let (frames, failure) = frames_of(b"\n\n\n\nnot json\n", None, &mut idle).await;
4344 assert_eq!(frames.len(), 5);
4345 assert!(failure.is_none(), "idle bytes must not count");
4346
4347 let mut active = TurnBytes {
4348 limit: Some(10),
4349 used: None,
4350 };
4351 active.start();
4352 let (frames, failure) = frames_of(b"\n\n\n\nnot json\n", None, &mut active).await;
4353 assert_eq!(frames.len(), 4, "the malformed line would reach 13 bytes");
4354 assert!(matches!(
4355 failure,
4356 Some(PolicyFailure::Limit {
4357 limit: DuplexLimit::TurnBytes,
4358 max: 10
4359 })
4360 ));
4361 active.stop();
4362 active.start();
4363 assert_eq!(active.used, Some(0), "the budget resets per turn");
4364 }
4365
4366 #[tokio::test]
4367 async fn frame_reader_keeps_partial_bytes_across_a_cancelled_read() {
4368 let (mut writer, reader) = tokio::io::duplex(64);
4369 let mut frames = FrameReader::new(reader, None);
4370 let mut turn = TurnBytes::default();
4371 writer.write_all(br#"{"type":"#).await.unwrap();
4372 let cancelled =
4373 tokio::time::timeout(Duration::from_millis(20), frames.next_frame(&mut turn)).await;
4374 assert!(cancelled.is_err(), "no complete frame is available yet");
4375 writer.write_all(b"\"assistant\"}\n").await.unwrap();
4376 match frames.next_frame(&mut turn).await {
4377 Ok(FrameRead::Frame(frame)) => assert_eq!(frame, br#"{"type":"assistant"}"#),
4378 _ => panic!("expected the reassembled frame"),
4379 }
4380 }
4381
4382 #[test]
4383 fn event_limit_stops_retention_and_broadcast_but_not_results_or_acks() {
4384 let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
4385 let (events_tx, mut events_rx) = broadcast::channel(16);
4386 let mut pending = Some((tx, Vec::new()));
4387 let limit = Some(1);
4388 assert!(matches!(
4389 handle_inbound(
4390 json!({"type": "assistant"}),
4391 &mut pending,
4392 &events_tx,
4393 limit
4394 ),
4395 InboundAction::None
4396 ));
4397 assert!(matches!(
4398 handle_inbound(
4399 json!({"type": "assistant"}),
4400 &mut pending,
4401 &events_tx,
4402 limit
4403 ),
4404 InboundAction::EventLimit(1)
4405 ));
4406 let ack = json!({
4407 "type": "control_response",
4408 "response": {"request_id": "interrupt-1", "subtype": "success"}
4409 });
4410 assert!(matches!(
4411 handle_inbound(ack, &mut pending, &events_tx, limit),
4412 InboundAction::ControlResponse { .. }
4413 ));
4414 assert!(events_rx.try_recv().is_ok());
4415 assert!(events_rx.try_recv().is_err(), "overflow must not broadcast");
4416 assert!(matches!(
4417 handle_inbound(json!({"type": "result"}), &mut pending, &events_tx, limit),
4418 InboundAction::None
4419 ));
4420 assert_eq!(rx.blocking_recv().unwrap().unwrap().events.len(), 1);
4421 }
4422
4423 #[test]
4424 fn policy_failures_render_distinguishable_errors() {
4425 let limit = PolicyFailure::Limit {
4426 limit: DuplexLimit::TurnEvents,
4427 max: 7,
4428 }
4429 .error(true);
4430 assert!(matches!(
4431 limit,
4432 Error::DuplexLimitExceeded {
4433 limit: DuplexLimit::TurnEvents,
4434 max: 7,
4435 process_settled: true,
4436 ..
4437 }
4438 ));
4439 let deadline = PolicyFailure::Deadline {
4440 operation: DuplexOperation::Control,
4441 timeout: Duration::from_millis(5),
4442 }
4443 .error(false);
4444 assert!(matches!(
4445 deadline,
4446 Error::DuplexDeadlineExceeded {
4447 operation: DuplexOperation::Control,
4448 process_settled: false,
4449 ..
4450 }
4451 ));
4452 assert!(deadline.to_string().contains("control request deadline"));
4453 }
4454
4455 #[tokio::test]
4456 async fn turn_budget_counts_a_partial_frame_started_while_idle() {
4457 let (mut writer, reader) = tokio::io::duplex(128);
4458 let mut frames = FrameReader::new(reader, None);
4459 let mut turn = TurnBytes {
4460 limit: Some(20),
4461 used: None,
4462 };
4463 let prefix = br#"{"type":"assistant","text":"a long prefix"#;
4464 writer.write_all(prefix).await.unwrap();
4465 tokio::select! {
4466 biased;
4467 _ = frames.next_frame(&mut turn) => panic!("frame is incomplete"),
4468 _ = tokio::task::yield_now() => {},
4469 }
4470 assert_eq!(frames.partial, prefix);
4471 turn.start();
4472 writer.write_all(b"\"}\n").await.unwrap();
4473 assert!(
4474 matches!(
4475 frames.next_frame(&mut turn).await,
4476 Err(FrameError::Limit(PolicyFailure::Limit {
4477 limit: DuplexLimit::TurnBytes,
4478 ..
4479 }))
4480 ),
4481 "the retained frame exceeds the turn budget even though its prefix was read while idle"
4482 );
4483 }
4484}