1use std::collections::{HashMap, VecDeque};
207use std::future::Future;
208use std::pin::Pin;
209use std::process::{ExitStatus, Stdio};
210use std::sync::Arc;
211use std::time::Duration;
212
213use serde_json::Value;
214use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
215use tokio::process::{Child, ChildStdin, ChildStdout, Command};
216use tokio::sync::{broadcast, mpsc, oneshot, watch};
217use tokio::task::JoinHandle;
218use tracing::{Instrument, debug, warn};
219
220use crate::Claude;
221use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
222use crate::error::{Error, Result};
223use crate::tool_pattern::ToolPattern;
224use crate::types::{Effort, HermeticScope, PermissionMode};
225
226pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
231
232#[derive(Debug, Clone)]
241pub struct PermissionRequest {
242 pub request_id: String,
245 pub tool_name: String,
247 pub input: Value,
249 pub raw: Value,
252}
253
254#[derive(Debug, Clone)]
263pub enum PermissionDecision {
264 Allow {
266 updated_input: Option<Value>,
269 },
270 Deny {
272 message: String,
274 },
275 Defer,
278}
279
280type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
281type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
282
283#[derive(Clone)]
296pub struct PermissionHandler {
297 inner: Arc<PermissionFn>,
298}
299
300impl PermissionHandler {
301 pub fn new<F, Fut>(f: F) -> Self
317 where
318 F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
319 Fut: Future<Output = PermissionDecision> + Send + 'static,
320 {
321 Self {
322 inner: Arc::new(move |req| Box::pin(f(req))),
323 }
324 }
325
326 fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
327 (self.inner)(req)
328 }
329}
330
331impl std::fmt::Debug for PermissionHandler {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 f.debug_struct("PermissionHandler").finish_non_exhaustive()
334 }
335}
336
337#[derive(Debug, Default, Clone)]
365pub struct DuplexOptions {
366 shared: SharedSpawnArgs,
369 additional_args: Vec<String>,
370 subscriber_capacity: Option<usize>,
371 on_permission: Option<PermissionHandler>,
372}
373
374impl DuplexOptions {
375 #[must_use]
377 pub fn model(mut self, model: impl Into<String>) -> Self {
378 self.shared.model = Some(model.into());
379 self
380 }
381
382 #[must_use]
384 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
385 self.shared.system_prompt = Some(prompt.into());
386 self
387 }
388
389 #[must_use]
391 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
392 self.shared.append_system_prompt = Some(prompt.into());
393 self
394 }
395
396 #[must_use]
413 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
414 self.shared.resume = Some(session_id.into());
415 self
416 }
417
418 #[must_use]
425 pub fn continue_session(mut self) -> Self {
426 self.shared.continue_session = true;
427 self
428 }
429
430 #[must_use]
441 pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
442 self.shared.worktree = true;
443 if let Some(n) = name {
444 self.shared.worktree_name = Some(n.into());
445 }
446 self
447 }
448
449 #[must_use]
463 pub fn agent(mut self, name: impl Into<String>) -> Self {
464 self.shared.agent = Some(name.into());
465 self
466 }
467
468 #[must_use]
480 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
481 self.shared.agents_json = Some(json.into());
482 self
483 }
484
485 #[must_use]
501 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
502 self.shared.permission_mode = Some(mode);
503 self
504 }
505
506 #[must_use]
510 pub fn forward_subagent_text(mut self) -> Self {
511 self.shared.forward_subagent_text = true;
512 self
513 }
514
515 #[must_use]
519 pub fn allow_dangerously_skip_permissions(mut self) -> Self {
520 self.shared.allow_dangerously_skip_permissions = true;
521 self
522 }
523
524 #[must_use]
532 pub fn dangerously_skip_permissions(mut self) -> Self {
533 self.shared.dangerously_skip_permissions = true;
534 self
535 }
536
537 #[must_use]
547 pub fn session_id(mut self, id: impl Into<String>) -> Self {
548 self.shared.session_id = Some(id.into());
549 self
550 }
551
552 #[must_use]
560 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
561 self.shared.json_schema = Some(schema.into());
562 self
563 }
564
565 #[must_use]
573 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
574 where
575 I: IntoIterator<Item = T>,
576 T: Into<ToolPattern>,
577 {
578 self.shared
579 .allowed_tools
580 .extend(tools.into_iter().map(Into::into));
581 self
582 }
583
584 #[must_use]
586 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
587 self.shared.allowed_tools.push(tool.into());
588 self
589 }
590
591 #[must_use]
593 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
594 where
595 I: IntoIterator<Item = T>,
596 T: Into<ToolPattern>,
597 {
598 self.shared
599 .disallowed_tools
600 .extend(tools.into_iter().map(Into::into));
601 self
602 }
603
604 #[must_use]
606 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
607 self.shared.disallowed_tools.push(tool.into());
608 self
609 }
610
611 #[must_use]
617 pub fn max_turns(mut self, turns: u32) -> Self {
618 self.shared.max_turns = Some(turns);
619 self
620 }
621
622 #[must_use]
632 pub fn max_budget_usd(mut self, budget: f64) -> Self {
633 self.shared.max_budget_usd = Some(budget);
634 self
635 }
636
637 #[must_use]
640 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
641 self.shared.fallback_model = Some(model.into());
642 self
643 }
644
645 #[must_use]
647 pub fn effort(mut self, effort: Effort) -> Self {
648 self.shared.effort = Some(effort);
649 self
650 }
651
652 #[must_use]
655 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
656 self.shared.add_dir.push(dir.into());
657 self
658 }
659
660 #[must_use]
665 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
666 self.shared.mcp_config.push(path.into());
667 self
668 }
669
670 #[must_use]
674 pub fn strict_mcp_config(mut self) -> Self {
675 self.shared.strict_mcp_config = true;
676 self
677 }
678
679 #[must_use]
685 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
686 self.shared.setting_sources = Some(sources.into());
687 self
688 }
689
690 #[must_use]
703 pub fn hermetic(mut self) -> Self {
704 self.shared.apply_hermetic(HermeticScope::Full);
705 self
706 }
707
708 #[must_use]
714 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
715 self.shared.apply_hermetic(scope);
716 self
717 }
718
719 #[must_use]
722 pub fn no_session_persistence(mut self) -> Self {
723 self.shared.no_session_persistence = true;
724 self
725 }
726
727 #[must_use]
735 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
736 self.shared.tools.extend(tools.into_iter().map(Into::into));
737 self
738 }
739
740 #[must_use]
745 pub fn file(mut self, spec: impl Into<String>) -> Self {
746 self.shared.file.push(spec.into());
747 self
748 }
749
750 #[must_use]
754 pub fn settings(mut self, settings: impl Into<String>) -> Self {
755 self.shared.settings = Some(settings.into());
756 self
757 }
758
759 #[must_use]
766 pub fn fork_session(mut self) -> Self {
767 self.shared.fork_session = true;
768 self
769 }
770
771 #[must_use]
775 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
776 self.shared.debug_filter = Some(filter.into());
777 self
778 }
779
780 #[must_use]
784 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
785 self.shared.debug_file = Some(path.into());
786 self
787 }
788
789 #[must_use]
793 pub fn betas(mut self, betas: impl Into<String>) -> Self {
794 self.shared.betas = Some(betas.into());
795 self
796 }
797
798 #[must_use]
802 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
803 self.shared.plugin_dirs.push(dir.into());
804 self
805 }
806
807 #[must_use]
811 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
812 self.shared.plugin_urls.push(url.into());
813 self
814 }
815
816 #[must_use]
820 pub fn tmux(mut self) -> Self {
821 self.shared.tmux = true;
822 self
823 }
824
825 #[must_use]
833 pub fn bare(mut self) -> Self {
834 self.shared.bare = true;
835 self
836 }
837
838 #[must_use]
845 pub fn safe_mode(mut self) -> Self {
846 self.shared.safe_mode = true;
847 self
848 }
849
850 #[must_use]
855 pub fn disable_slash_commands(mut self) -> Self {
856 self.shared.disable_slash_commands = true;
857 self
858 }
859
860 #[must_use]
867 pub fn include_hook_events(mut self) -> Self {
868 self.shared.include_hook_events = true;
869 self
870 }
871
872 #[must_use]
881 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
882 self.shared.exclude_dynamic_system_prompt_sections = true;
883 self
884 }
885
886 #[must_use]
890 pub fn name(mut self, name: impl Into<String>) -> Self {
891 self.shared.name = Some(name.into());
892 self
893 }
894
895 #[must_use]
900 pub fn arg(mut self, arg: impl Into<String>) -> Self {
901 self.additional_args.push(arg.into());
902 self
903 }
904
905 #[must_use]
913 pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
914 self.subscriber_capacity = Some(capacity);
915 self
916 }
917
918 #[must_use]
936 pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
937 self.on_permission = Some(handler);
938 self
939 }
940
941 fn build_args(&self) -> Vec<String> {
942 let mut args = vec![
943 "--print".to_string(),
944 "--verbose".to_string(),
945 "--output-format".to_string(),
946 "stream-json".to_string(),
947 "--input-format".to_string(),
948 "stream-json".to_string(),
949 ];
950
951 self.shared.append_to(&mut args);
952
953 if self.on_permission.is_some() {
954 args.push("--permission-prompt-tool".to_string());
955 args.push("stdio".to_string());
956 }
957 args.extend(self.additional_args.iter().cloned());
958
959 args
960 }
961
962 fn spawn_command_args(&self, claude: &Claude) -> Vec<String> {
968 let mut args = claude.global_args.clone();
969 args.extend(self.build_args());
970 args
971 }
972
973 #[must_use]
1002 pub fn to_command_string(&self, claude: &Claude) -> String {
1003 let args = self.spawn_command_args(claude);
1004 let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
1005 format!("{} {}", claude.binary().display(), quoted_args.join(" "))
1006 }
1007}
1008
1009#[derive(Debug, Clone)]
1016pub struct TurnResult {
1017 pub result: Value,
1019 pub events: Vec<Value>,
1021}
1022
1023impl TurnResult {
1024 #[must_use]
1026 pub fn result_text(&self) -> Option<&str> {
1027 self.result.get("result").and_then(Value::as_str)
1028 }
1029
1030 #[must_use]
1032 pub fn session_id(&self) -> Option<&str> {
1033 self.result.get("session_id").and_then(Value::as_str)
1034 }
1035
1036 #[must_use]
1052 pub fn total_cost_usd(&self) -> Option<f64> {
1053 self.result
1054 .get("total_cost_usd")
1055 .or_else(|| self.result.get("cost_usd"))
1056 .and_then(Value::as_f64)
1057 }
1058
1059 #[must_use]
1061 pub fn duration_ms(&self) -> Option<u64> {
1062 self.result.get("duration_ms").and_then(Value::as_u64)
1063 }
1064
1065 #[must_use]
1069 pub fn subtype(&self) -> Option<&str> {
1070 self.result.get("subtype").and_then(Value::as_str)
1071 }
1072
1073 #[must_use]
1076 pub fn is_error(&self) -> bool {
1077 self.result
1078 .get("is_error")
1079 .and_then(Value::as_bool)
1080 .unwrap_or(false)
1081 }
1082}
1083
1084pub(crate) fn turn_cost_delta(prev_total: &mut Option<f64>, total: Option<f64>) -> Option<f64> {
1092 let total = total?;
1093 let prev = prev_total.replace(total).unwrap_or(0.0);
1094 Some((total - prev).max(0.0))
1095}
1096
1097#[derive(Debug, Clone)]
1111pub enum InboundEvent {
1112 SystemInit {
1115 session_id: String,
1118 },
1119 Assistant(Value),
1122 StreamEvent(Value),
1125 User(Value),
1128 Other(Value),
1131}
1132
1133fn classify(msg: &Value) -> InboundEvent {
1134 match msg.get("type").and_then(Value::as_str) {
1135 Some("system") => {
1136 if msg.get("subtype").and_then(Value::as_str) == Some("init")
1137 && let Some(id) = msg.get("session_id").and_then(Value::as_str)
1138 {
1139 return InboundEvent::SystemInit {
1140 session_id: id.to_string(),
1141 };
1142 }
1143 InboundEvent::Other(msg.clone())
1144 }
1145 Some("assistant") => InboundEvent::Assistant(msg.clone()),
1146 Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
1147 Some("user") => InboundEvent::User(msg.clone()),
1148 _ => InboundEvent::Other(msg.clone()),
1149 }
1150}
1151
1152#[derive(Debug, Clone)]
1167pub enum SessionExitStatus {
1168 Running,
1170 Completed,
1172 Failed(String),
1175}
1176
1177#[derive(Debug)]
1186pub struct DuplexSession {
1187 outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
1188 events_tx: broadcast::Sender<InboundEvent>,
1189 exit_rx: watch::Receiver<SessionExitStatus>,
1190 join: JoinHandle<Result<()>>,
1191}
1192
1193#[derive(Debug)]
1194enum OutboundMsg {
1195 Send {
1196 prompt: String,
1197 reply: oneshot::Sender<Result<TurnResult>>,
1198 },
1199 PermissionResponse {
1200 request_id: String,
1201 decision: PermissionDecision,
1202 },
1203 Interrupt {
1204 reply: oneshot::Sender<Result<()>>,
1205 },
1206}
1207
1208impl DuplexSession {
1209 pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
1217 let capacity = opts
1218 .subscriber_capacity
1219 .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
1220 let permission_handler = opts.on_permission.clone();
1221
1222 let command_args = opts.spawn_command_args(claude);
1223
1224 debug!(
1225 arg_count = command_args.len(),
1226 "spawning duplex claude session"
1227 );
1228
1229 let mut cmd = Command::new(&claude.binary);
1230 cmd.args(&command_args)
1231 .stdin(Stdio::piped())
1232 .stdout(Stdio::piped())
1233 .stderr(Stdio::piped())
1234 .kill_on_drop(true);
1235 crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
1236 crate::exec::apply_process_group(&mut cmd, claude.process_group);
1240
1241 if let Some(ref dir) = claude.working_dir {
1242 cmd.current_dir(dir);
1243 }
1244
1245 let mut child = crate::exec::spawn_retrying_txtbsy(&mut cmd)
1246 .await
1247 .map_err(|e| Error::Io {
1248 message: format!("failed to spawn claude: {e}"),
1249 source: e,
1250 working_dir: claude.working_dir.clone(),
1251 })?;
1252 let group =
1253 crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
1254
1255 let stdin = child.stdin.take().expect("stdin was piped");
1256 let stdout = child.stdout.take().expect("stdout was piped");
1257
1258 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1259 let (events_tx, _initial_rx) = broadcast::channel(capacity);
1260 let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1261
1262 let session_span = tracing::debug_span!(
1271 "claude.session",
1272 model_configured = opts.shared.model.is_some(),
1273 permission_mode = opts
1274 .shared
1275 .permission_mode
1276 .as_ref()
1277 .map(|m| m.as_arg())
1278 .unwrap_or("default"),
1279 resumed = opts.shared.resume.is_some(),
1280 turns = tracing::field::Empty,
1281 exit = tracing::field::Empty,
1282 );
1283 let join = tokio::spawn(
1284 run_session(
1285 child,
1286 group,
1287 claude.kill_grace,
1288 claude.termination_signal,
1289 stdin,
1290 stdout,
1291 outbound_rx,
1292 events_tx.clone(),
1293 permission_handler,
1294 exit_tx,
1295 )
1296 .instrument(session_span),
1297 );
1298
1299 Ok(Self {
1300 outbound_tx,
1301 events_tx,
1302 exit_rx,
1303 join,
1304 })
1305 }
1306
1307 pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1322 let (reply_tx, reply_rx) = oneshot::channel();
1323 self.outbound_tx
1324 .send(OutboundMsg::Send {
1325 prompt: prompt.into(),
1326 reply: reply_tx,
1327 })
1328 .map_err(|_| Error::DuplexClosed)?;
1329 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1330 }
1331
1332 #[must_use]
1367 pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1368 self.events_tx.subscribe()
1369 }
1370
1371 #[must_use]
1382 pub fn is_alive(&self) -> bool {
1383 matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1384 }
1385
1386 #[must_use]
1395 pub fn exit_status(&self) -> SessionExitStatus {
1396 self.exit_rx.borrow().clone()
1397 }
1398
1399 pub async fn wait_for_exit(&self) -> SessionExitStatus {
1411 let mut rx = self.exit_rx.clone();
1412 loop {
1413 {
1414 let value = rx.borrow_and_update();
1415 if !matches!(*value, SessionExitStatus::Running) {
1416 return value.clone();
1417 }
1418 }
1419 if rx.changed().await.is_err() {
1420 return rx.borrow().clone();
1421 }
1422 }
1423 }
1424
1425 pub fn respond_to_permission(
1470 &self,
1471 request_id: impl Into<String>,
1472 decision: PermissionDecision,
1473 ) -> Result<()> {
1474 if matches!(decision, PermissionDecision::Defer) {
1475 warn!("respond_to_permission called with Defer; ignoring");
1476 return Ok(());
1477 }
1478 self.outbound_tx
1479 .send(OutboundMsg::PermissionResponse {
1480 request_id: request_id.into(),
1481 decision,
1482 })
1483 .map_err(|_| Error::DuplexClosed)?;
1484 Ok(())
1485 }
1486
1487 pub async fn interrupt(&self) -> Result<()> {
1528 let (reply_tx, reply_rx) = oneshot::channel();
1529 self.outbound_tx
1530 .send(OutboundMsg::Interrupt { reply: reply_tx })
1531 .map_err(|_| Error::DuplexClosed)?;
1532 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1533 }
1534
1535 pub async fn close(self) -> Result<()> {
1547 drop(self.outbound_tx);
1548 drop(self.events_tx);
1549 match self.join.await {
1550 Ok(result) => result,
1551 Err(e) if e.is_cancelled() => Ok(()),
1552 Err(e) => Err(Error::Io {
1553 message: format!("duplex session task panicked: {e}"),
1554 source: std::io::Error::other(e.to_string()),
1555 working_dir: None,
1556 }),
1557 }
1558 }
1559}
1560
1561const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1565
1566const STDERR_TAIL_BYTES: usize = 64 * 1024;
1569const STDERR_SETTLE_BUDGET: Duration = Duration::from_millis(200);
1570
1571async fn read_stderr_tail<R: tokio::io::AsyncRead + Unpin>(
1572 reader: &mut R,
1573 tail: &mut VecDeque<u8>,
1574) -> std::io::Result<()> {
1575 let mut chunk = [0u8; 8192];
1576 loop {
1577 let read = match reader.read(&mut chunk).await {
1578 Ok(0) => return Ok(()),
1579 Ok(read) => read,
1580 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
1581 Err(error) => return Err(error),
1582 };
1583 let excess = (tail.len() + read).saturating_sub(STDERR_TAIL_BYTES);
1584 tail.drain(..excess);
1585 tail.extend(&chunk[..read]);
1586 }
1587}
1588
1589#[allow(clippy::too_many_arguments)]
1590async fn run_session(
1591 mut child: Child,
1592 group: crate::exec::GroupKillGuard,
1593 kill_grace: Option<Duration>,
1594 termination_signal: crate::TerminationSignal,
1595 stdin: ChildStdin,
1596 stdout: ChildStdout,
1597 outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1598 events_tx: broadcast::Sender<InboundEvent>,
1599 permission_handler: Option<PermissionHandler>,
1600 exit_tx: watch::Sender<SessionExitStatus>,
1601) -> Result<()> {
1602 let mut stderr = child.stderr.take().expect("stderr was piped");
1603 let mut tail = VecDeque::new();
1604 let (stderr_failed_tx, stderr_failed_rx) = oneshot::channel();
1605 let (status, stderr_result) = {
1608 let drain_stderr = async {
1609 let result = read_stderr_tail(&mut stderr, &mut tail).await;
1610 if result.is_err() {
1611 let _ = stderr_failed_tx.send(());
1612 }
1613 result
1614 };
1615 let session = run_session_child(
1616 child,
1617 group,
1618 kill_grace,
1619 termination_signal,
1620 stdin,
1621 stdout,
1622 outbound_rx,
1623 events_tx,
1624 permission_handler,
1625 stderr_failed_rx,
1626 );
1627 tokio::pin!(drain_stderr);
1628 tokio::pin!(session);
1629 tokio::select! {
1630 status = &mut session => {
1631 let read = tokio::time::timeout(
1634 STDERR_SETTLE_BUDGET, &mut drain_stderr,
1635 ).await.ok();
1636 (status, read)
1637 }
1638 read = &mut drain_stderr => (session.await, Some(read)),
1639 }
1640 };
1641 let stderr = String::from_utf8_lossy(&tail.into_iter().collect::<Vec<_>>()).into_owned();
1642 let result = resident_outcome(status, stderr_result, stderr);
1643 let final_state = match &result {
1644 Ok(()) => SessionExitStatus::Completed,
1645 Err(error) => SessionExitStatus::Failed(error.to_string()),
1646 };
1647 tracing::Span::current().record(
1648 "exit",
1649 if result.is_ok() {
1650 "completed"
1651 } else {
1652 "failed"
1653 },
1654 );
1655 let _ = exit_tx.send(final_state);
1656 result
1657}
1658
1659fn resident_outcome(
1660 status: Result<(ExitStatus, bool)>,
1661 stderr_result: Option<std::io::Result<()>>,
1662 stderr: String,
1663) -> Result<()> {
1664 match (status, stderr_result) {
1665 (Err(error), _) => Err(error),
1666 (Ok((status, shutdown_budget_exceeded)), read) if !status.success() => {
1667 Err(Error::DuplexProcessFailed {
1668 status,
1669 shutdown_budget_exceeded,
1670 stderr,
1671 stderr_read_error: read.and_then(std::result::Result::err),
1672 })
1673 }
1674 (Ok(_), Some(Err(source))) => Err(Error::Io {
1675 message: format!("failed to read duplex stderr; retained diagnostics: {stderr}"),
1676 source,
1677 working_dir: None,
1678 }),
1679 (Ok(_), _) => Ok(()),
1680 }
1681}
1682
1683#[allow(clippy::too_many_arguments)]
1684async fn run_session_child(
1685 mut child: Child,
1686 mut group: crate::exec::GroupKillGuard,
1687 kill_grace: Option<Duration>,
1688 termination_signal: crate::TerminationSignal,
1689 mut stdin: ChildStdin,
1690 stdout: ChildStdout,
1691 mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1692 events_tx: broadcast::Sender<InboundEvent>,
1693 permission_handler: Option<PermissionHandler>,
1694 mut stderr_failed: oneshot::Receiver<()>,
1695) -> Result<(ExitStatus, bool)> {
1696 let mut lines = BufReader::new(stdout).lines();
1697 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1698 let mut pending_control: HashMap<String, oneshot::Sender<Result<()>>> = HashMap::new();
1699 let mut next_control_id: u64 = 0;
1700 let mut stream_err: Option<Error> = None;
1701 let mut stderr_failure_open = true;
1702 let session_span = tracing::Span::current();
1704 let mut turns: u64 = 0;
1705 let mut turn_span: Option<tracing::Span> = None;
1706 let mut turn_started: Option<std::time::Instant> = None;
1707 let mut last_total_cost: Option<f64> = None;
1710
1711 loop {
1712 tokio::select! {
1713 biased;
1714
1715 line = lines.next_line() => match line {
1716 Ok(Some(l)) => {
1717 if l.trim().is_empty() {
1718 continue;
1719 }
1720 let parsed = match serde_json::from_str::<Value>(&l) {
1721 Ok(v) => v,
1722 Err(e) => {
1723 debug!(line_bytes = l.len(), json_error = ?e.classify(), "failed to parse duplex event, skipping");
1724 continue;
1725 }
1726 };
1727 if let Some("result") = parsed.get("type").and_then(Value::as_str) {
1729 let total = parsed
1730 .get("total_cost_usd")
1731 .or_else(|| parsed.get("cost_usd"))
1732 .and_then(Value::as_f64);
1733 let cost = turn_cost_delta(&mut last_total_cost, total);
1734 if let Some(span) = turn_span.take() {
1735 span.record(
1736 "is_error",
1737 parsed.get("is_error").and_then(Value::as_bool).unwrap_or(false),
1738 );
1739 if let Some(sub) = parsed.get("subtype").and_then(Value::as_str) {
1740 let label = match sub {
1741 "success" => "success",
1742 "error_during_execution" => "error_during_execution",
1743 "error_max_turns" => "error_max_turns",
1744 "error_max_budget_usd" => "error_max_budget_usd",
1745 _ => "other",
1746 };
1747 span.record("subtype", label);
1748 }
1749 if let (Some(c), Some(t)) = (cost, total) {
1750 span.record("cost_usd", c);
1751 span.record("total_cost_usd", t);
1752 }
1753 if let Some(started) = turn_started.take() {
1754 span.record(
1755 "duration_ms",
1756 started.elapsed().as_millis() as u64,
1757 );
1758 }
1759 }
1760 }
1761 match handle_inbound(parsed, &mut pending, &events_tx) {
1762 InboundAction::None => {}
1763 InboundAction::Permission(req) => {
1764 let request_id = req.request_id.clone();
1765 let decision = match permission_handler.as_ref() {
1766 Some(h) => h.invoke(req).await,
1767 None => {
1768 warn!("received can_use_tool with no permission handler; auto-denying");
1769 PermissionDecision::Deny {
1770 message:
1771 "no permission handler configured on duplex session"
1772 .into(),
1773 }
1774 }
1775 };
1776 if matches!(decision, PermissionDecision::Defer) {
1777 debug!(
1778 "permission handler deferred; waiting for respond_to_permission"
1779 );
1780 } else if write_permission_response(&mut stdin, &request_id, &decision).await.is_err() {
1781 warn!("failed to write permission response");
1782 }
1783 }
1784 InboundAction::ControlResponse { request_id, outcome } => {
1785 if let Some(reply) = pending_control.remove(&request_id) {
1786 let _ = reply.send(outcome);
1787 } else {
1788 debug!(
1789 "received control_response with no pending request"
1790 );
1791 }
1792 }
1793 }
1794 }
1795 Ok(None) => break,
1796 Err(e) => {
1797 stream_err = Some(Error::Io {
1798 message: "failed to read duplex stdout".to_string(),
1799 source: e,
1800 working_dir: None,
1801 });
1802 break;
1803 }
1804 },
1805
1806 failure = &mut stderr_failed, if stderr_failure_open => {
1807 stderr_failure_open = false;
1808 if failure.is_ok() {
1809 break;
1810 }
1811 },
1812
1813 msg = outbound_rx.recv() => match msg {
1814 Some(OutboundMsg::Send { prompt, reply }) => {
1815 if pending.is_some() {
1816 let _ = reply.send(Err(Error::DuplexTurnInFlight));
1817 continue;
1818 }
1819 if let Err(e) = write_user(&mut stdin, &prompt).await {
1820 let _ = reply.send(Err(e));
1821 continue;
1822 }
1823 turns += 1;
1824 turn_span = Some(tracing::debug_span!(
1829 parent: &session_span,
1830 "claude.turn",
1831 turn = turns,
1832 is_error = tracing::field::Empty,
1833 subtype = tracing::field::Empty,
1834 cost_usd = tracing::field::Empty,
1835 total_cost_usd = tracing::field::Empty,
1836 duration_ms = tracing::field::Empty,
1837 ));
1838 turn_started = Some(std::time::Instant::now());
1839 pending = Some((reply, Vec::new()));
1840 }
1841 Some(OutboundMsg::PermissionResponse { request_id, decision }) => {
1842 if write_permission_response(&mut stdin, &request_id, &decision).await.is_err() {
1843 warn!("failed to write deferred permission response");
1844 }
1845 }
1846 Some(OutboundMsg::Interrupt { reply }) => {
1847 next_control_id += 1;
1848 let request_id = format!("interrupt-{next_control_id}");
1849 if let Err(e) =
1850 write_control_request(&mut stdin, &request_id, "interrupt").await
1851 {
1852 let _ = reply.send(Err(e));
1853 continue;
1854 }
1855 pending_control.insert(request_id, reply);
1856 }
1857 None => break,
1858 },
1859 }
1860 }
1861
1862 drop(stdin);
1863 let mut stdout = lines.into_inner();
1864 let mut unused_stderr = tokio::io::empty();
1867 let waited = crate::exec::drain_until(
1868 tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()),
1869 &mut stdout,
1870 &mut unused_stderr,
1871 )
1872 .await;
1873 let status = match waited {
1874 Ok(Ok(status)) => {
1875 group.disarm();
1876 Ok((status, false))
1877 }
1878 Ok(Err(source)) => {
1879 let error = Error::Io {
1880 message: "failed to wait for duplex child".into(),
1881 source,
1882 working_dir: None,
1883 };
1884 match crate::exec::drain_until(
1885 crate::exec::stop_and_reap(
1886 &mut child,
1887 &mut group,
1888 kill_grace,
1889 termination_signal,
1890 None,
1891 ),
1892 &mut stdout,
1893 &mut unused_stderr,
1894 )
1895 .await
1896 {
1897 Ok(()) => Err(error),
1898 Err(cleanup) => Err(cleanup),
1899 }
1900 }
1901 Err(_) => {
1902 warn!("duplex child did not exit within shutdown budget; stopping");
1903 match crate::exec::drain_until(
1904 crate::exec::stop_and_reap(
1905 &mut child,
1906 &mut group,
1907 kill_grace,
1908 termination_signal,
1909 None,
1910 ),
1911 &mut stdout,
1912 &mut unused_stderr,
1913 )
1914 .await
1915 {
1916 Ok(()) => child
1917 .wait()
1918 .await
1919 .map(|status| (status, true))
1920 .map_err(|source| Error::Io {
1921 message: "failed to wait for stopped duplex child".into(),
1922 source,
1923 working_dir: None,
1924 }),
1925 Err(error) => Err(error),
1926 }
1927 }
1928 };
1929
1930 if let Some((reply, _)) = pending.take() {
1931 let _ = reply.send(Err(Error::DuplexClosed));
1932 }
1933 for (_, reply) in pending_control.drain() {
1934 let _ = reply.send(Err(Error::DuplexClosed));
1935 }
1936
1937 session_span.record("turns", turns);
1938 match stream_err {
1939 Some(error) => Err(error),
1940 None => status,
1941 }
1942}
1943
1944enum InboundAction {
1948 None,
1950 Permission(PermissionRequest),
1954 ControlResponse {
1959 request_id: String,
1960 outcome: Result<()>,
1961 },
1962}
1963
1964fn handle_inbound(
1965 msg: Value,
1966 pending: &mut Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>,
1967 events_tx: &broadcast::Sender<InboundEvent>,
1968) -> InboundAction {
1969 match msg.get("type").and_then(Value::as_str) {
1970 Some("result") => {
1971 if let Some((reply, events)) = pending.take() {
1972 let _ = reply.send(Ok(TurnResult {
1973 result: msg,
1974 events,
1975 }));
1976 } else {
1977 debug!("dropping orphan result event with no pending turn");
1978 }
1979 InboundAction::None
1980 }
1981 Some("control_request") => {
1982 if msg
1985 .get("request")
1986 .and_then(|r| r.get("subtype"))
1987 .and_then(Value::as_str)
1988 == Some("can_use_tool")
1989 && let Some(req) = parse_permission_request(&msg)
1990 {
1991 if let Some((_, events)) = pending.as_mut() {
1992 events.push(msg);
1993 }
1994 return InboundAction::Permission(req);
1995 }
1996 debug!("received unhandled control_request; treating as Other");
1997 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1998 if let Some((_, events)) = pending.as_mut() {
1999 events.push(msg);
2000 }
2001 InboundAction::None
2002 }
2003 Some("control_response") => {
2004 if let Some((request_id, outcome)) = parse_control_response(&msg) {
2005 return InboundAction::ControlResponse {
2006 request_id,
2007 outcome,
2008 };
2009 }
2010 debug!("received malformed control_response; treating as Other");
2011 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
2012 if let Some((_, events)) = pending.as_mut() {
2013 events.push(msg);
2014 }
2015 InboundAction::None
2016 }
2017 _ => {
2018 let _ = events_tx.send(classify(&msg));
2021
2022 if let Some((_, events)) = pending.as_mut() {
2023 events.push(msg);
2024 } else {
2025 debug!("dropping inbound event with no pending turn");
2026 }
2027 InboundAction::None
2028 }
2029 }
2030}
2031
2032fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
2033 let request_id = msg.get("request_id").and_then(Value::as_str)?;
2034 let request = msg.get("request")?;
2035 let tool_name = request.get("tool_name").and_then(Value::as_str)?;
2036 let input = request.get("input").cloned().unwrap_or(Value::Null);
2037 Some(PermissionRequest {
2038 request_id: request_id.to_string(),
2039 tool_name: tool_name.to_string(),
2040 input,
2041 raw: request.clone(),
2042 })
2043}
2044
2045fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
2051 let response = msg.get("response")?;
2052 let request_id = response.get("request_id").and_then(Value::as_str)?;
2053 let outcome = match response.get("subtype").and_then(Value::as_str) {
2054 Some("success") => Ok(()),
2055 Some("error") => {
2056 let message = response
2057 .get("error")
2058 .and_then(Value::as_str)
2059 .unwrap_or("unknown control_response error")
2060 .to_string();
2061 Err(Error::DuplexControlFailed { message })
2062 }
2063 _ => return None,
2064 };
2065 Some((request_id.to_string(), outcome))
2066}
2067
2068async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
2069 let user_msg = serde_json::json!({
2070 "type": "user",
2071 "message": {
2072 "role": "user",
2073 "content": prompt,
2074 },
2075 "parent_tool_use_id": null,
2076 });
2077 write_line(stdin, &user_msg, "user message").await
2078}
2079
2080async fn write_control_request(
2081 stdin: &mut ChildStdin,
2082 request_id: &str,
2083 subtype: &str,
2084) -> Result<()> {
2085 let envelope = serde_json::json!({
2086 "type": "control_request",
2087 "request_id": request_id,
2088 "request": { "subtype": subtype },
2089 });
2090 write_line(stdin, &envelope, "control_request").await
2091}
2092
2093async fn write_permission_response(
2094 stdin: &mut ChildStdin,
2095 request_id: &str,
2096 decision: &PermissionDecision,
2097) -> Result<()> {
2098 let inner = match decision {
2099 PermissionDecision::Allow { updated_input } => {
2100 let mut obj = serde_json::Map::new();
2101 obj.insert("behavior".to_string(), Value::String("allow".to_string()));
2102 if let Some(input) = updated_input {
2103 obj.insert("updatedInput".to_string(), input.clone());
2104 }
2105 Value::Object(obj)
2106 }
2107 PermissionDecision::Deny { message } => serde_json::json!({
2108 "behavior": "deny",
2109 "message": message,
2110 }),
2111 PermissionDecision::Defer => {
2112 return Ok(());
2114 }
2115 };
2116 let envelope = serde_json::json!({
2117 "type": "control_response",
2118 "response": {
2119 "request_id": request_id,
2120 "subtype": "success",
2121 "response": inner,
2122 },
2123 });
2124 write_line(stdin, &envelope, "control_response").await
2125}
2126
2127async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
2128 let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
2129 message: format!("failed to serialize duplex {what}"),
2130 source: e,
2131 })?;
2132 line.push('\n');
2133 stdin
2134 .write_all(line.as_bytes())
2135 .await
2136 .map_err(|e| Error::Io {
2137 message: format!("failed to write {what} to duplex stdin"),
2138 source: e,
2139 working_dir: None,
2140 })?;
2141 stdin.flush().await.map_err(|e| Error::Io {
2142 message: "failed to flush duplex stdin".to_string(),
2143 source: e,
2144 working_dir: None,
2145 })?;
2146 Ok(())
2147}
2148
2149#[cfg(test)]
2150mod tests {
2151 use super::*;
2152 use serde_json::json;
2153
2154 #[test]
2155 fn build_args_default_includes_required_flags() {
2156 let args = DuplexOptions::default().build_args();
2157 assert!(args.contains(&"--print".to_string()));
2158 assert!(args.contains(&"--verbose".to_string()));
2159 assert!(
2160 args.windows(2)
2161 .any(|w| w == ["--output-format", "stream-json"])
2162 );
2163 assert!(
2164 args.windows(2)
2165 .any(|w| w == ["--input-format", "stream-json"])
2166 );
2167 }
2168
2169 #[test]
2170 fn build_args_includes_model() {
2171 let args = DuplexOptions::default().model("haiku").build_args();
2172 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2173 }
2174
2175 #[test]
2176 fn build_args_includes_system_prompts() {
2177 let args = DuplexOptions::default()
2178 .system_prompt("be concise")
2179 .append_system_prompt("also polite")
2180 .build_args();
2181 assert!(
2182 args.windows(2)
2183 .any(|w| w == ["--system-prompt", "be concise"])
2184 );
2185 assert!(
2186 args.windows(2)
2187 .any(|w| w == ["--append-system-prompt", "also polite"])
2188 );
2189 }
2190
2191 #[test]
2192 fn build_args_appends_raw_args_last() {
2193 let args = DuplexOptions::default()
2194 .arg("--add-dir")
2195 .arg("/tmp/foo")
2196 .build_args();
2197 assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
2199 }
2200
2201 fn preview_claude() -> Claude {
2204 Claude::builder()
2205 .binary("/usr/local/bin/claude")
2206 .build()
2207 .unwrap()
2208 }
2209
2210 #[test]
2211 fn spawn_command_args_prepends_global_args() {
2212 let claude = Claude::builder()
2213 .binary("/usr/local/bin/claude")
2214 .arg("--debug")
2215 .build()
2216 .unwrap();
2217 let args = DuplexOptions::default()
2218 .model("haiku")
2219 .spawn_command_args(&claude);
2220 assert_eq!(args[0], "--debug");
2221 assert_eq!(args[1], "--print");
2222 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
2223 }
2224
2225 #[test]
2226 fn to_command_string_is_binary_plus_spawn_args() {
2227 let claude = Claude::builder()
2230 .binary("/usr/local/bin/claude")
2231 .arg("--debug")
2232 .build()
2233 .unwrap();
2234 let opts = DuplexOptions::default().agent("reviewer");
2235 let expected = format!(
2236 "/usr/local/bin/claude {}",
2237 opts.spawn_command_args(&claude).join(" ")
2238 );
2239 assert_eq!(opts.to_command_string(&claude), expected);
2240 }
2241
2242 #[test]
2243 fn to_command_string_includes_persona_flags() {
2244 let command_str = DuplexOptions::default()
2247 .agent("reviewer")
2248 .allowed_tool("Read")
2249 .allowed_tool("Bash(git:*)")
2250 .setting_sources("project")
2251 .to_command_string(&preview_claude());
2252 assert!(command_str.starts_with("/usr/local/bin/claude"));
2253 assert!(command_str.contains("--agent reviewer"));
2254 assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
2255 assert!(command_str.contains("--setting-sources project"));
2256 }
2257
2258 #[test]
2259 fn to_command_string_quotes_args_with_spaces() {
2260 let command_str = DuplexOptions::default()
2261 .system_prompt("be concise")
2262 .to_command_string(&preview_claude());
2263 assert!(command_str.contains("--system-prompt 'be concise'"));
2264 }
2265
2266 #[test]
2267 fn to_command_string_does_not_consume_options() {
2268 let claude = preview_claude();
2271 let opts = DuplexOptions::default().model("haiku");
2272 let first = opts.to_command_string(&claude);
2273 let second = opts.to_command_string(&claude);
2274 assert_eq!(first, second);
2275 }
2276
2277 #[test]
2278 fn build_args_includes_resume_when_set() {
2279 let args = DuplexOptions::default().resume("abc-123").build_args();
2280 assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
2281 }
2282
2283 #[test]
2284 fn build_args_omits_resume_by_default() {
2285 let args = DuplexOptions::default().build_args();
2286 assert!(
2287 !args.iter().any(|a| a == "--resume"),
2288 "--resume should not appear without an explicit resume(...) call; got {args:?}"
2289 );
2290 }
2291
2292 #[test]
2293 fn build_args_includes_continue_when_set() {
2294 let args = DuplexOptions::default().continue_session().build_args();
2295 assert!(args.iter().any(|a| a == "--continue"));
2296 }
2297
2298 #[test]
2299 fn build_args_omits_continue_by_default() {
2300 let args = DuplexOptions::default().build_args();
2301 assert!(!args.iter().any(|a| a == "--continue"));
2302 }
2303
2304 #[test]
2305 fn build_args_includes_worktree_flag_without_name() {
2306 let args = DuplexOptions::default().worktree(None::<&str>).build_args();
2307 assert!(args.iter().any(|a| a == "--worktree"));
2308 let pos = args.iter().position(|a| a == "--worktree").unwrap();
2310 assert!(
2311 args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
2312 "--worktree without a name should not be followed by a positional; got {args:?}"
2313 );
2314 }
2315
2316 #[test]
2317 fn build_args_includes_worktree_flag_with_name() {
2318 let args = DuplexOptions::default()
2319 .worktree(Some("agent-xyz"))
2320 .build_args();
2321 let pos = args.iter().position(|a| a == "--worktree").unwrap();
2322 assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
2323 }
2324
2325 #[test]
2326 fn build_args_omits_worktree_by_default() {
2327 let args = DuplexOptions::default().build_args();
2328 assert!(
2329 !args.iter().any(|a| a == "--worktree"),
2330 "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
2331 );
2332 }
2333
2334 #[test]
2335 fn worktree_lands_before_additional_args() {
2336 let args = DuplexOptions::default()
2338 .worktree(Some("foo"))
2339 .arg("--")
2340 .arg("trailing")
2341 .build_args();
2342 let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
2343 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2344 assert!(
2345 wt_pos < dash_dash_pos,
2346 "--worktree must precede `--` separator; got {args:?}"
2347 );
2348 }
2349
2350 #[test]
2351 fn build_args_includes_agent_when_set() {
2352 let args = DuplexOptions::default().agent("rust-qa").build_args();
2353 assert!(
2354 args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
2355 "missing --agent rust-qa in {args:?}"
2356 );
2357 }
2358
2359 #[test]
2360 fn build_args_omits_agent_by_default() {
2361 let args = DuplexOptions::default().build_args();
2362 assert!(
2363 !args.iter().any(|a| a == "--agent"),
2364 "--agent should not appear without an explicit agent(...) call; got {args:?}"
2365 );
2366 }
2367
2368 #[test]
2369 fn build_args_includes_agents_json_when_set() {
2370 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2371 let args = DuplexOptions::default().agents_json(json).build_args();
2372 let pos = args.iter().position(|a| a == "--agents").unwrap();
2373 assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
2374 }
2375
2376 #[test]
2377 fn build_args_omits_agents_json_by_default() {
2378 let args = DuplexOptions::default().build_args();
2379 assert!(!args.iter().any(|a| a == "--agents"));
2380 }
2381
2382 #[test]
2383 fn agent_and_agents_json_compose() {
2384 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
2385 let args = DuplexOptions::default()
2386 .agents_json(json)
2387 .agent("reviewer")
2388 .build_args();
2389 assert!(args.iter().any(|a| a == "--agents"));
2391 assert!(args.iter().any(|a| a == "--agent"));
2392 }
2393
2394 #[test]
2395 fn agent_lands_before_additional_args() {
2396 let args = DuplexOptions::default()
2397 .agent("rust-qa")
2398 .arg("--")
2399 .arg("trailing")
2400 .build_args();
2401 let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
2402 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2403 assert!(
2404 agent_pos < dash_dash_pos,
2405 "--agent must precede `--` separator; got {args:?}"
2406 );
2407 }
2408
2409 #[test]
2410 fn agents_json_lands_before_additional_args() {
2411 let args = DuplexOptions::default()
2412 .agents_json("{}")
2413 .arg("--")
2414 .arg("trailing")
2415 .build_args();
2416 let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
2417 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2418 assert!(
2419 agents_pos < dash_dash_pos,
2420 "--agents must precede `--` separator; got {args:?}"
2421 );
2422 }
2423
2424 #[test]
2427 fn build_args_includes_session_id() {
2428 let args = DuplexOptions::default().session_id("sid-9").build_args();
2429 assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
2430 }
2431
2432 #[test]
2433 fn build_args_includes_setting_sources() {
2434 let args = DuplexOptions::default()
2435 .setting_sources("user,project")
2436 .build_args();
2437 assert!(
2438 args.windows(2)
2439 .any(|w| w == ["--setting-sources", "user,project"]),
2440 "got {args:?}"
2441 );
2442 }
2443
2444 #[test]
2445 fn build_args_omits_setting_sources_by_default() {
2446 let args = DuplexOptions::default().build_args();
2447 assert!(!args.iter().any(|a| a == "--setting-sources"));
2448 }
2449
2450 #[test]
2451 fn build_args_hermetic_emits_full_seal() {
2452 let args = DuplexOptions::default().hermetic().build_args();
2453 assert!(
2454 args.windows(2)
2455 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
2456 "got {args:?}"
2457 );
2458 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2459 assert!(
2460 args.iter()
2461 .any(|a| a == "--exclude-dynamic-system-prompt-sections")
2462 );
2463 assert!(!args.iter().any(|a| a == "--bare"));
2465 }
2466
2467 #[test]
2468 fn build_args_hermetic_scoped_project_keeps_user() {
2469 let args = DuplexOptions::default()
2470 .hermetic_scoped(HermeticScope::Project)
2471 .build_args();
2472 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
2473 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2474 }
2475
2476 #[test]
2477 fn build_args_includes_json_schema() {
2478 let schema = r#"{"type":"object"}"#;
2479 let args = DuplexOptions::default().json_schema(schema).build_args();
2480 assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
2481 }
2482
2483 #[test]
2484 fn build_args_joins_allowed_tools_comma_separated() {
2485 let args = DuplexOptions::default()
2486 .allowed_tools(["Read", "Bash(git log:*)"])
2487 .allowed_tool("Write")
2488 .build_args();
2489 assert!(
2490 args.windows(2)
2491 .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
2492 "missing joined --allowed-tools in {args:?}"
2493 );
2494 }
2495
2496 #[test]
2497 fn build_args_joins_disallowed_tools_comma_separated() {
2498 let args = DuplexOptions::default()
2499 .disallowed_tools(["WebSearch"])
2500 .disallowed_tool("WebFetch")
2501 .build_args();
2502 assert!(
2503 args.windows(2)
2504 .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
2505 "missing joined --disallowed-tools in {args:?}"
2506 );
2507 }
2508
2509 #[test]
2510 fn build_args_includes_caps() {
2511 let args = DuplexOptions::default()
2512 .max_turns(4)
2513 .max_budget_usd(0.25)
2514 .build_args();
2515 assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
2516 assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
2517 }
2518
2519 #[test]
2520 fn build_args_includes_fallback_model_and_effort() {
2521 let args = DuplexOptions::default()
2522 .fallback_model("haiku")
2523 .effort(Effort::Low)
2524 .build_args();
2525 assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
2526 assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
2527 }
2528
2529 #[test]
2530 fn build_args_repeats_add_dir_and_mcp_config() {
2531 let args = DuplexOptions::default()
2532 .add_dir("/a")
2533 .add_dir("/b")
2534 .mcp_config("x.json")
2535 .strict_mcp_config()
2536 .build_args();
2537 assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
2538 assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
2539 assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
2540 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2541 }
2542
2543 #[test]
2544 fn build_args_includes_no_session_persistence() {
2545 let args = DuplexOptions::default()
2546 .no_session_persistence()
2547 .build_args();
2548 assert!(args.iter().any(|a| a == "--no-session-persistence"));
2549 }
2550
2551 #[test]
2554 fn build_args_joins_tools_comma_separated() {
2555 let args = DuplexOptions::default()
2556 .tools(["Bash", "Read", "Edit"])
2557 .build_args();
2558 assert!(
2559 args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
2560 "missing joined --tools in {args:?}"
2561 );
2562 }
2563
2564 #[test]
2565 fn build_args_repeats_file_per_spec() {
2566 let args = DuplexOptions::default()
2567 .file("file_a:doc.txt")
2568 .file("file_b:notes.md")
2569 .build_args();
2570 assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
2571 assert!(args.iter().any(|a| a == "file_a:doc.txt"));
2572 assert!(args.iter().any(|a| a == "file_b:notes.md"));
2573 }
2574
2575 #[test]
2576 fn build_args_includes_settings() {
2577 let args = DuplexOptions::default()
2578 .settings("/tmp/settings.json")
2579 .build_args();
2580 assert!(
2581 args.windows(2)
2582 .any(|w| w == ["--settings", "/tmp/settings.json"])
2583 );
2584 }
2585
2586 #[test]
2587 fn build_args_includes_fork_session() {
2588 let args = DuplexOptions::default().fork_session().build_args();
2589 assert!(args.iter().any(|a| a == "--fork-session"));
2590 }
2591
2592 #[test]
2593 fn build_args_includes_debug_filter_and_file() {
2594 let args = DuplexOptions::default()
2595 .debug_filter("api,hooks")
2596 .debug_file("/tmp/debug.log")
2597 .build_args();
2598 assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
2599 assert!(
2600 args.windows(2)
2601 .any(|w| w == ["--debug-file", "/tmp/debug.log"])
2602 );
2603 }
2604
2605 #[test]
2606 fn build_args_includes_betas() {
2607 let args = DuplexOptions::default().betas("feature-x").build_args();
2608 assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
2609 }
2610
2611 #[test]
2612 fn build_args_repeats_plugin_dir_and_url() {
2613 let args = DuplexOptions::default()
2614 .plugin_dir("/plugins/a")
2615 .plugin_dir("/plugins/b")
2616 .plugin_url("https://example.com/p.zip")
2617 .build_args();
2618 assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
2619 assert!(
2620 args.windows(2)
2621 .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
2622 );
2623 }
2624
2625 #[test]
2626 fn build_args_includes_bare_family_bool_flags() {
2627 let args = DuplexOptions::default()
2628 .tmux()
2629 .bare()
2630 .safe_mode()
2631 .disable_slash_commands()
2632 .include_hook_events()
2633 .exclude_dynamic_system_prompt_sections()
2634 .build_args();
2635 for flag in [
2636 "--tmux",
2637 "--bare",
2638 "--safe-mode",
2639 "--disable-slash-commands",
2640 "--include-hook-events",
2641 "--exclude-dynamic-system-prompt-sections",
2642 ] {
2643 assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
2644 }
2645 }
2646
2647 #[test]
2648 fn build_args_includes_name() {
2649 let args = DuplexOptions::default().name("my session").build_args();
2650 assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
2651 }
2652
2653 #[test]
2654 fn build_args_omits_promoted_parity_flags_by_default() {
2655 let args = DuplexOptions::default().build_args();
2656 for flag in [
2657 "--tools",
2658 "--file",
2659 "--settings",
2660 "--fork-session",
2661 "--debug",
2662 "--debug-file",
2663 "--betas",
2664 "--plugin-dir",
2665 "--plugin-url",
2666 "--tmux",
2667 "--bare",
2668 "--safe-mode",
2669 "--disable-slash-commands",
2670 "--include-hook-events",
2671 "--exclude-dynamic-system-prompt-sections",
2672 "--name",
2673 ] {
2674 assert!(
2675 !args.iter().any(|a| a == flag),
2676 "{flag} should be absent by default; got {args:?}"
2677 );
2678 }
2679 }
2680
2681 #[test]
2682 fn parity_flags_land_before_additional_args() {
2683 let args = DuplexOptions::default()
2685 .max_turns(2)
2686 .json_schema("{}")
2687 .arg("--")
2688 .arg("trailing")
2689 .build_args();
2690 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2691 for flag in ["--max-turns", "--json-schema"] {
2692 let pos = args.iter().position(|a| a == flag).unwrap();
2693 assert!(
2694 pos < dash_dash_pos,
2695 "{flag} must precede `--` separator; got {args:?}"
2696 );
2697 }
2698 }
2699
2700 #[test]
2701 fn build_args_omits_parity_flags_by_default() {
2702 let args = DuplexOptions::default().build_args();
2703 for flag in [
2704 "--session-id",
2705 "--json-schema",
2706 "--allowed-tools",
2707 "--disallowed-tools",
2708 "--max-turns",
2709 "--max-budget-usd",
2710 "--fallback-model",
2711 "--effort",
2712 "--add-dir",
2713 "--mcp-config",
2714 "--strict-mcp-config",
2715 "--no-session-persistence",
2716 ] {
2717 assert!(
2718 !args.iter().any(|a| a == flag),
2719 "{flag} should not appear by default; got {args:?}"
2720 );
2721 }
2722 }
2723
2724 #[test]
2725 fn resume_lands_before_additional_args() {
2726 let args = DuplexOptions::default()
2731 .resume("xyz")
2732 .arg("--")
2733 .arg("trailing")
2734 .build_args();
2735 let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
2736 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2737 assert!(
2738 resume_pos < dash_dash_pos,
2739 "--resume must precede `--` separator; got {args:?}"
2740 );
2741 }
2742
2743 #[test]
2744 fn turn_result_accessors_pull_from_result() {
2745 let r = TurnResult {
2746 result: json!({
2747 "type": "result",
2748 "result": "hello",
2749 "session_id": "sess-123",
2750 "total_cost_usd": 0.0042,
2751 "duration_ms": 1234_u64,
2752 }),
2753 events: vec![],
2754 };
2755 assert_eq!(r.result_text(), Some("hello"));
2756 assert_eq!(r.session_id(), Some("sess-123"));
2757 assert_eq!(r.total_cost_usd(), Some(0.0042));
2758 assert_eq!(r.duration_ms(), Some(1234));
2759 }
2760
2761 #[test]
2762 fn turn_result_total_cost_falls_back_to_legacy_field() {
2763 let r = TurnResult {
2764 result: json!({ "cost_usd": 0.5 }),
2765 events: vec![],
2766 };
2767 assert_eq!(r.total_cost_usd(), Some(0.5));
2768 }
2769
2770 #[test]
2771 fn turn_result_accessors_return_none_when_missing() {
2772 let r = TurnResult {
2773 result: json!({}),
2774 events: vec![],
2775 };
2776 assert_eq!(r.result_text(), None);
2777 assert_eq!(r.session_id(), None);
2778 assert_eq!(r.total_cost_usd(), None);
2779 assert_eq!(r.duration_ms(), None);
2780 assert_eq!(r.subtype(), None);
2781 assert!(!r.is_error());
2782 }
2783
2784 #[test]
2785 fn turn_result_subtype_and_is_error_for_interrupted_turn() {
2786 let r = TurnResult {
2787 result: json!({
2788 "type": "result",
2789 "subtype": "error_during_execution",
2790 "is_error": true,
2791 "total_cost_usd": 0.0004,
2792 }),
2793 events: vec![],
2794 };
2795 assert_eq!(r.subtype(), Some("error_during_execution"));
2796 assert!(r.is_error());
2797 assert_eq!(r.result_text(), None);
2798 }
2799
2800 #[test]
2801 fn turn_cost_delta_tracks_previous_total() {
2802 let mut prev = None;
2803 assert_eq!(turn_cost_delta(&mut prev, Some(0.25)), Some(0.25));
2804 assert_eq!(turn_cost_delta(&mut prev, Some(0.75)), Some(0.5));
2805 assert_eq!(turn_cost_delta(&mut prev, None), None);
2807 assert_eq!(prev, Some(0.75));
2808 assert_eq!(turn_cost_delta(&mut prev, Some(0.5)), Some(0.0));
2810 assert_eq!(turn_cost_delta(&mut prev, Some(1.0)), Some(0.5));
2811 }
2812
2813 #[test]
2814 fn handle_inbound_appends_non_result_to_pending_events() {
2815 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2816 let (events_tx, _events_rx) = broadcast::channel(16);
2817 let mut pending = Some((tx, Vec::new()));
2818 handle_inbound(
2819 json!({ "type": "assistant", "message": {} }),
2820 &mut pending,
2821 &events_tx,
2822 );
2823 let (_, events) = pending.as_ref().unwrap();
2824 assert_eq!(events.len(), 1);
2825 assert_eq!(
2826 events[0].get("type").and_then(Value::as_str),
2827 Some("assistant")
2828 );
2829 }
2830
2831 #[test]
2832 fn handle_inbound_resolves_pending_on_result() {
2833 let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
2834 let (events_tx, _events_rx) = broadcast::channel(16);
2835 let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
2836 handle_inbound(
2837 json!({ "type": "result", "result": "ok" }),
2838 &mut pending,
2839 &events_tx,
2840 );
2841 assert!(pending.is_none());
2842 let received = rx.blocking_recv().unwrap().unwrap();
2843 assert_eq!(received.result_text(), Some("ok"));
2844 assert_eq!(received.events.len(), 1);
2845 }
2846
2847 #[test]
2848 fn handle_inbound_drops_orphans_without_pending_turn() {
2849 let (events_tx, _events_rx) = broadcast::channel(16);
2850 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
2851 handle_inbound(json!({ "type": "assistant" }), &mut pending, &events_tx);
2852 handle_inbound(
2853 json!({ "type": "result", "result": "ok" }),
2854 &mut pending,
2855 &events_tx,
2856 );
2857 assert!(pending.is_none());
2858 }
2859
2860 #[test]
2861 fn handle_inbound_broadcasts_classified_event() {
2862 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2863 let (events_tx, mut events_rx) = broadcast::channel(16);
2864 let mut pending = Some((tx, Vec::new()));
2865 handle_inbound(
2866 json!({ "type": "assistant", "message": { "role": "assistant" } }),
2867 &mut pending,
2868 &events_tx,
2869 );
2870 let event = events_rx.try_recv().expect("classified event broadcast");
2871 assert!(matches!(event, InboundEvent::Assistant(_)));
2872 }
2873
2874 #[test]
2875 fn handle_inbound_does_not_broadcast_result() {
2876 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2877 let (events_tx, mut events_rx) = broadcast::channel(16);
2878 let mut pending = Some((tx, Vec::new()));
2879 handle_inbound(
2880 json!({ "type": "result", "result": "ok" }),
2881 &mut pending,
2882 &events_tx,
2883 );
2884 assert!(events_rx.try_recv().is_err());
2886 }
2887
2888 #[test]
2889 fn classify_system_init_pulls_session_id() {
2890 let v = json!({
2891 "type": "system",
2892 "subtype": "init",
2893 "session_id": "sess-abc",
2894 });
2895 match classify(&v) {
2896 InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
2897 other => panic!("expected SystemInit, got {other:?}"),
2898 }
2899 }
2900
2901 #[test]
2902 fn classify_system_without_init_subtype_is_other() {
2903 let v = json!({ "type": "system", "subtype": "compaction" });
2904 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2905 }
2906
2907 #[test]
2908 fn classify_system_init_without_session_id_is_other() {
2909 let v = json!({ "type": "system", "subtype": "init" });
2910 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2911 }
2912
2913 #[test]
2914 fn classify_assistant_stream_event_user() {
2915 assert!(matches!(
2916 classify(&json!({ "type": "assistant" })),
2917 InboundEvent::Assistant(_)
2918 ));
2919 assert!(matches!(
2920 classify(&json!({ "type": "stream_event" })),
2921 InboundEvent::StreamEvent(_)
2922 ));
2923 assert!(matches!(
2924 classify(&json!({ "type": "user" })),
2925 InboundEvent::User(_)
2926 ));
2927 }
2928
2929 #[test]
2930 fn classify_unknown_type_is_other() {
2931 assert!(matches!(
2932 classify(&json!({ "type": "control_request" })),
2933 InboundEvent::Other(_)
2934 ));
2935 assert!(matches!(
2936 classify(&json!({ "type": "future_thing" })),
2937 InboundEvent::Other(_)
2938 ));
2939 assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
2940 }
2941
2942 #[test]
2943 fn build_args_does_not_emit_subscriber_capacity_flag() {
2944 let args = DuplexOptions::default()
2946 .subscriber_capacity(64)
2947 .build_args();
2948 assert!(!args.iter().any(|a| a.contains("subscriber")));
2949 assert!(!args.iter().any(|a| a.contains("capacity")));
2950 }
2951
2952 #[test]
2953 fn build_args_includes_permission_prompt_tool_when_handler_set() {
2954 let handler = PermissionHandler::new(|_req| async move {
2955 PermissionDecision::Allow {
2956 updated_input: None,
2957 }
2958 });
2959 let args = DuplexOptions::default().on_permission(handler).build_args();
2960 assert!(
2961 args.windows(2)
2962 .any(|w| w == ["--permission-prompt-tool", "stdio"])
2963 );
2964 }
2965
2966 #[test]
2967 fn build_args_omits_permission_prompt_tool_without_handler() {
2968 let args = DuplexOptions::default().build_args();
2969 assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
2970 }
2971
2972 #[test]
2973 fn build_args_emits_permission_mode_flag() {
2974 let args = DuplexOptions::default()
2975 .permission_mode(PermissionMode::AcceptEdits)
2976 .build_args();
2977 assert!(
2978 args.windows(2)
2979 .any(|w| w == ["--permission-mode", "acceptEdits"]),
2980 "missing --permission-mode acceptEdits in {args:?}"
2981 );
2982 }
2983
2984 #[test]
2985 fn build_args_emits_plan_mode() {
2986 let args = DuplexOptions::default()
2987 .permission_mode(PermissionMode::Plan)
2988 .build_args();
2989 assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
2990 }
2991
2992 #[test]
2993 fn build_args_omits_permission_mode_by_default() {
2994 let args = DuplexOptions::default().build_args();
2995 assert!(!args.iter().any(|a| a == "--permission-mode"));
2996 }
2997
2998 #[test]
2999 fn build_args_emits_dangerously_skip_permissions_flag() {
3000 let args = DuplexOptions::default()
3001 .dangerously_skip_permissions()
3002 .build_args();
3003 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
3004 }
3005
3006 #[test]
3007 fn build_args_omits_dangerously_skip_by_default() {
3008 let args = DuplexOptions::default().build_args();
3009 assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
3010 }
3011
3012 #[test]
3013 fn parse_permission_request_extracts_fields() {
3014 let msg = json!({
3015 "type": "control_request",
3016 "request_id": "req-1",
3017 "request": {
3018 "subtype": "can_use_tool",
3019 "tool_name": "Bash",
3020 "input": { "command": "ls" }
3021 }
3022 });
3023 let req = parse_permission_request(&msg).expect("permission request");
3024 assert_eq!(req.request_id, "req-1");
3025 assert_eq!(req.tool_name, "Bash");
3026 assert_eq!(req.input, json!({ "command": "ls" }));
3027 assert_eq!(
3028 req.raw.get("subtype").and_then(Value::as_str),
3029 Some("can_use_tool")
3030 );
3031 }
3032
3033 #[test]
3034 fn parse_permission_request_returns_none_when_missing_request_id() {
3035 let msg = json!({
3036 "type": "control_request",
3037 "request": {
3038 "subtype": "can_use_tool",
3039 "tool_name": "Bash",
3040 }
3041 });
3042 assert!(parse_permission_request(&msg).is_none());
3043 }
3044
3045 #[test]
3046 fn parse_permission_request_returns_none_when_missing_tool_name() {
3047 let msg = json!({
3048 "type": "control_request",
3049 "request_id": "req-1",
3050 "request": { "subtype": "can_use_tool" }
3051 });
3052 assert!(parse_permission_request(&msg).is_none());
3053 }
3054
3055 #[test]
3056 fn parse_permission_request_handles_missing_input() {
3057 let msg = json!({
3058 "type": "control_request",
3059 "request_id": "req-1",
3060 "request": {
3061 "subtype": "can_use_tool",
3062 "tool_name": "Bash",
3063 }
3064 });
3065 let req = parse_permission_request(&msg).expect("request");
3066 assert_eq!(req.input, Value::Null);
3067 }
3068
3069 #[test]
3070 fn handle_inbound_returns_permission_for_can_use_tool() {
3071 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3072 let (events_tx, _events_rx) = broadcast::channel(16);
3073 let mut pending = Some((tx, Vec::new()));
3074 let action = handle_inbound(
3075 json!({
3076 "type": "control_request",
3077 "request_id": "req-1",
3078 "request": {
3079 "subtype": "can_use_tool",
3080 "tool_name": "Bash",
3081 "input": { "command": "ls" }
3082 }
3083 }),
3084 &mut pending,
3085 &events_tx,
3086 );
3087 match action {
3088 InboundAction::Permission(req) => {
3089 assert_eq!(req.request_id, "req-1");
3090 assert_eq!(req.tool_name, "Bash");
3091 }
3092 InboundAction::None | InboundAction::ControlResponse { .. } => {
3093 panic!("expected Permission action");
3094 }
3095 }
3096 let (_, events) = pending.as_ref().unwrap();
3098 assert_eq!(events.len(), 1);
3099 }
3100
3101 #[test]
3102 fn handle_inbound_treats_unknown_control_request_as_other() {
3103 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3104 let (events_tx, mut events_rx) = broadcast::channel(16);
3105 let mut pending = Some((tx, Vec::new()));
3106 let action = handle_inbound(
3107 json!({
3108 "type": "control_request",
3109 "request_id": "req-2",
3110 "request": { "subtype": "future_subtype" }
3111 }),
3112 &mut pending,
3113 &events_tx,
3114 );
3115 assert!(matches!(action, InboundAction::None));
3116 let event = events_rx.try_recv().expect("broadcast");
3117 assert!(matches!(event, InboundEvent::Other(_)));
3118 }
3119
3120 #[tokio::test]
3121 async fn permission_handler_invokes_closure_async() {
3122 let handler = PermissionHandler::new(|req| async move {
3123 if req.tool_name == "Bash" {
3124 PermissionDecision::Deny {
3125 message: "no bash".into(),
3126 }
3127 } else {
3128 PermissionDecision::Allow {
3129 updated_input: None,
3130 }
3131 }
3132 });
3133 let req = PermissionRequest {
3134 request_id: "r1".into(),
3135 tool_name: "Bash".into(),
3136 input: Value::Null,
3137 raw: Value::Null,
3138 };
3139 match handler.invoke(req).await {
3140 PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
3141 other => panic!("expected Deny, got {other:?}"),
3142 }
3143 }
3144
3145 #[test]
3146 fn parse_control_response_extracts_success() {
3147 let msg = json!({
3148 "type": "control_response",
3149 "response": {
3150 "request_id": "interrupt-1",
3151 "subtype": "success",
3152 "response": {}
3153 }
3154 });
3155 let (id, outcome) = parse_control_response(&msg).expect("parsed");
3156 assert_eq!(id, "interrupt-1");
3157 assert!(outcome.is_ok());
3158 }
3159
3160 #[test]
3161 fn parse_control_response_extracts_error_with_message() {
3162 let msg = json!({
3163 "type": "control_response",
3164 "response": {
3165 "request_id": "interrupt-2",
3166 "subtype": "error",
3167 "error": "no turn in flight"
3168 }
3169 });
3170 let (id, outcome) = parse_control_response(&msg).expect("parsed");
3171 assert_eq!(id, "interrupt-2");
3172 match outcome {
3173 Err(Error::DuplexControlFailed { message }) => {
3174 assert_eq!(message, "no turn in flight");
3175 }
3176 other => panic!("expected DuplexControlFailed, got {other:?}"),
3177 }
3178 }
3179
3180 #[test]
3181 fn parse_control_response_returns_none_on_missing_request_id() {
3182 let msg = json!({
3183 "type": "control_response",
3184 "response": { "subtype": "success" }
3185 });
3186 assert!(parse_control_response(&msg).is_none());
3187 }
3188
3189 #[test]
3190 fn parse_control_response_returns_none_on_unknown_subtype() {
3191 let msg = json!({
3192 "type": "control_response",
3193 "response": { "request_id": "x", "subtype": "future_subtype" }
3194 });
3195 assert!(parse_control_response(&msg).is_none());
3196 }
3197
3198 #[test]
3199 fn handle_inbound_returns_control_response_action() {
3200 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3201 let (events_tx, _events_rx) = broadcast::channel(16);
3202 let mut pending = Some((tx, Vec::new()));
3203 let action = handle_inbound(
3204 json!({
3205 "type": "control_response",
3206 "response": {
3207 "request_id": "interrupt-1",
3208 "subtype": "success",
3209 "response": {}
3210 }
3211 }),
3212 &mut pending,
3213 &events_tx,
3214 );
3215 match action {
3216 InboundAction::ControlResponse {
3217 request_id,
3218 outcome,
3219 } => {
3220 assert_eq!(request_id, "interrupt-1");
3221 assert!(outcome.is_ok());
3222 }
3223 InboundAction::None | InboundAction::Permission(_) => {
3224 panic!("expected ControlResponse action");
3225 }
3226 }
3227 }
3228
3229 #[test]
3230 fn handle_inbound_treats_malformed_control_response_as_other() {
3231 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
3232 let (events_tx, mut events_rx) = broadcast::channel(16);
3233 let mut pending = Some((tx, Vec::new()));
3234 let action = handle_inbound(
3235 json!({
3236 "type": "control_response",
3237 "response": { "subtype": "success" }
3238 }),
3239 &mut pending,
3240 &events_tx,
3241 );
3242 assert!(matches!(action, InboundAction::None));
3243 let event = events_rx.try_recv().expect("broadcast");
3244 assert!(matches!(event, InboundEvent::Other(_)));
3245 }
3246
3247 #[tokio::test]
3248 async fn permission_handler_clones_arc() {
3249 let handler = PermissionHandler::new(|_req| async move {
3250 PermissionDecision::Allow {
3251 updated_input: None,
3252 }
3253 });
3254 let cloned = handler.clone();
3255 let req = PermissionRequest {
3256 request_id: "r1".into(),
3257 tool_name: "Read".into(),
3258 input: Value::Null,
3259 raw: Value::Null,
3260 };
3261 let _ = handler.invoke(req.clone()).await;
3263 let _ = cloned.invoke(req).await;
3264 }
3265
3266 fn fake_session(
3273 initial: SessionExitStatus,
3274 ) -> (
3275 DuplexSession,
3276 watch::Sender<SessionExitStatus>,
3277 oneshot::Sender<()>,
3278 ) {
3279 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
3280 let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
3281 let (exit_tx, exit_rx) = watch::channel(initial);
3282 let (stop_tx, stop_rx) = oneshot::channel::<()>();
3283
3284 let join = tokio::spawn(async move {
3285 let _outbound_rx = outbound_rx;
3286 let _ = stop_rx.await;
3287 Ok::<(), Error>(())
3288 });
3289
3290 let session = DuplexSession {
3291 outbound_tx,
3292 events_tx,
3293 exit_rx,
3294 join,
3295 };
3296 (session, exit_tx, stop_tx)
3297 }
3298
3299 #[tokio::test]
3300 async fn is_alive_true_while_running() {
3301 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3302 assert!(session.is_alive());
3303 }
3304
3305 #[tokio::test]
3306 async fn is_alive_false_after_completed() {
3307 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3308 exit_tx.send(SessionExitStatus::Completed).unwrap();
3309 assert!(!session.is_alive());
3310 }
3311
3312 #[tokio::test]
3313 async fn is_alive_false_after_failed() {
3314 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3315 exit_tx
3316 .send(SessionExitStatus::Failed("boom".into()))
3317 .unwrap();
3318 assert!(!session.is_alive());
3319 }
3320
3321 #[tokio::test]
3322 async fn exit_status_reports_running_initially() {
3323 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3324 assert!(matches!(session.exit_status(), SessionExitStatus::Running));
3325 }
3326
3327 #[tokio::test]
3328 async fn exit_status_reflects_completed() {
3329 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3330 exit_tx.send(SessionExitStatus::Completed).unwrap();
3331 assert!(matches!(
3332 session.exit_status(),
3333 SessionExitStatus::Completed
3334 ));
3335 }
3336
3337 #[tokio::test]
3338 async fn exit_status_reflects_failed_with_message() {
3339 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3340 exit_tx
3341 .send(SessionExitStatus::Failed("oh no".into()))
3342 .unwrap();
3343 match session.exit_status() {
3344 SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
3345 other => panic!("expected Failed, got {other:?}"),
3346 }
3347 }
3348
3349 #[tokio::test]
3350 async fn wait_for_exit_returns_immediately_when_already_terminal() {
3351 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3352 exit_tx.send(SessionExitStatus::Completed).unwrap();
3353 let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
3354 .await
3355 .expect("wait_for_exit should not block when already terminal");
3356 assert!(matches!(status, SessionExitStatus::Completed));
3357 }
3358
3359 #[tokio::test]
3360 async fn wait_for_exit_blocks_until_state_transitions() {
3361 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3362
3363 let waiter = async { session.wait_for_exit().await };
3364 let driver = async {
3365 tokio::time::sleep(Duration::from_millis(20)).await;
3366 exit_tx.send(SessionExitStatus::Completed).unwrap();
3367 };
3368 let (status, ()) = tokio::join!(waiter, driver);
3369 assert!(matches!(status, SessionExitStatus::Completed));
3370 }
3371
3372 #[tokio::test]
3373 async fn wait_for_exit_supports_multiple_observers() {
3374 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3375
3376 let waiter1 = async { session.wait_for_exit().await };
3377 let waiter2 = async { session.wait_for_exit().await };
3378 let driver = async {
3379 tokio::time::sleep(Duration::from_millis(20)).await;
3380 exit_tx
3381 .send(SessionExitStatus::Failed("crash".into()))
3382 .unwrap();
3383 };
3384 let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
3385 match s1 {
3386 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3387 other => panic!("waiter1 expected Failed, got {other:?}"),
3388 }
3389 match s2 {
3390 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
3391 other => panic!("waiter2 expected Failed, got {other:?}"),
3392 }
3393 }
3394
3395 #[tokio::test]
3396 async fn wait_for_exit_returns_last_value_when_sender_dropped() {
3397 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
3401 let waiter = async { session.wait_for_exit().await };
3402 let driver = async {
3403 tokio::time::sleep(Duration::from_millis(20)).await;
3404 drop(exit_tx);
3405 };
3406 let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
3407 tokio::join!(waiter, driver)
3408 })
3409 .await
3410 .expect("wait_for_exit must not hang when sender is dropped");
3411 assert!(matches!(status, SessionExitStatus::Running));
3412 }
3413
3414 #[tokio::test]
3415 async fn stderr_reader_retries_interruptions_and_retains_bytes_on_failure() {
3416 struct Reader(u8);
3417 impl tokio::io::AsyncRead for Reader {
3418 fn poll_read(
3419 mut self: std::pin::Pin<&mut Self>,
3420 _: &mut std::task::Context<'_>,
3421 buf: &mut tokio::io::ReadBuf<'_>,
3422 ) -> std::task::Poll<std::io::Result<()>> {
3423 self.0 += 1;
3424 std::task::Poll::Ready(match self.0 {
3425 1 => Err(std::io::ErrorKind::Interrupted.into()),
3426 2 => {
3427 buf.put_slice(b"retained");
3428 Ok(())
3429 }
3430 _ => Err(std::io::Error::other("stderr read fault")),
3431 })
3432 }
3433 }
3434 let mut tail = VecDeque::new();
3435 let error = read_stderr_tail(&mut Reader(0), &mut tail)
3436 .await
3437 .unwrap_err();
3438 assert_eq!(error.to_string(), "stderr read fault");
3439 assert_eq!(tail.into_iter().collect::<Vec<_>>(), b"retained");
3440 }
3441
3442 #[cfg(unix)]
3443 #[test]
3444 fn stderr_read_failure_keeps_native_exit_evidence_and_diagnostics() {
3445 for code in [0, 7] {
3446 let status = std::process::Command::new("/bin/sh")
3447 .args(["-c", &format!("exit {code}")])
3448 .status()
3449 .unwrap();
3450 let result = resident_outcome(
3451 Ok((status, false)),
3452 Some(Err(std::io::Error::other("stderr read fault"))),
3453 "retained diagnostics".into(),
3454 );
3455 match result {
3456 Err(Error::DuplexProcessFailed {
3457 status,
3458 stderr,
3459 stderr_read_error,
3460 ..
3461 }) => {
3462 assert_eq!(status.code(), Some(7));
3463 assert_eq!(stderr, "retained diagnostics");
3464 assert_eq!(stderr_read_error.unwrap().to_string(), "stderr read fault");
3465 }
3466 Err(Error::Io {
3467 message, source, ..
3468 }) if code == 0 => {
3469 assert!(message.contains("retained diagnostics"));
3470 assert_eq!(source.to_string(), "stderr read fault");
3471 }
3472 other => panic!("expected retained exit/read evidence, got {other:?}"),
3473 }
3474 }
3475 }
3476 #[test]
3477 fn compatible_headless_flags_are_explicit_and_do_not_activate_bypass() {
3478 let defaults = DuplexOptions::default().build_args();
3479 assert!(
3480 !defaults.iter().any(|arg| arg == "--forward-subagent-text"
3481 || arg == "--allow-dangerously-skip-permissions")
3482 );
3483 let args = DuplexOptions::default()
3484 .forward_subagent_text()
3485 .allow_dangerously_skip_permissions()
3486 .build_args();
3487 assert!(args.iter().any(|arg| arg == "--forward-subagent-text"));
3488 assert!(
3489 args.iter()
3490 .any(|arg| arg == "--allow-dangerously-skip-permissions")
3491 );
3492 assert!(
3493 !args
3494 .iter()
3495 .any(|arg| arg == "--dangerously-skip-permissions" || arg == "bypassPermissions")
3496 );
3497 }
3498}