1use std::collections::HashMap;
192use std::future::Future;
193use std::pin::Pin;
194use std::process::Stdio;
195use std::sync::Arc;
196use std::time::Duration;
197
198use serde_json::Value;
199use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
200use tokio::process::{Child, ChildStdin, ChildStdout, Command};
201use tokio::sync::{broadcast, mpsc, oneshot, watch};
202use tokio::task::JoinHandle;
203use tracing::{debug, warn};
204
205use crate::Claude;
206use crate::command::spawn_args::SharedSpawnArgs;
207use crate::error::{Error, Result};
208use crate::tool_pattern::ToolPattern;
209use crate::types::{Effort, HermeticScope, PermissionMode};
210
211pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
216
217#[derive(Debug, Clone)]
226pub struct PermissionRequest {
227 pub request_id: String,
230 pub tool_name: String,
232 pub input: Value,
234 pub raw: Value,
237}
238
239#[derive(Debug, Clone)]
248pub enum PermissionDecision {
249 Allow {
251 updated_input: Option<Value>,
254 },
255 Deny {
257 message: String,
259 },
260 Defer,
263}
264
265type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
266type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
267
268#[derive(Clone)]
281pub struct PermissionHandler {
282 inner: Arc<PermissionFn>,
283}
284
285impl PermissionHandler {
286 pub fn new<F, Fut>(f: F) -> Self
302 where
303 F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
304 Fut: Future<Output = PermissionDecision> + Send + 'static,
305 {
306 Self {
307 inner: Arc::new(move |req| Box::pin(f(req))),
308 }
309 }
310
311 fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
312 (self.inner)(req)
313 }
314}
315
316impl std::fmt::Debug for PermissionHandler {
317 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318 f.debug_struct("PermissionHandler").finish_non_exhaustive()
319 }
320}
321
322#[derive(Debug, Default, Clone)]
350pub struct DuplexOptions {
351 shared: SharedSpawnArgs,
354 additional_args: Vec<String>,
355 subscriber_capacity: Option<usize>,
356 on_permission: Option<PermissionHandler>,
357}
358
359impl DuplexOptions {
360 #[must_use]
362 pub fn model(mut self, model: impl Into<String>) -> Self {
363 self.shared.model = Some(model.into());
364 self
365 }
366
367 #[must_use]
369 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
370 self.shared.system_prompt = Some(prompt.into());
371 self
372 }
373
374 #[must_use]
376 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
377 self.shared.append_system_prompt = Some(prompt.into());
378 self
379 }
380
381 #[must_use]
398 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
399 self.shared.resume = Some(session_id.into());
400 self
401 }
402
403 #[must_use]
410 pub fn continue_session(mut self) -> Self {
411 self.shared.continue_session = true;
412 self
413 }
414
415 #[must_use]
426 pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
427 self.shared.worktree = true;
428 if let Some(n) = name {
429 self.shared.worktree_name = Some(n.into());
430 }
431 self
432 }
433
434 #[must_use]
448 pub fn agent(mut self, name: impl Into<String>) -> Self {
449 self.shared.agent = Some(name.into());
450 self
451 }
452
453 #[must_use]
465 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
466 self.shared.agents_json = Some(json.into());
467 self
468 }
469
470 #[must_use]
486 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
487 self.shared.permission_mode = Some(mode);
488 self
489 }
490
491 #[must_use]
499 pub fn dangerously_skip_permissions(mut self) -> Self {
500 self.shared.dangerously_skip_permissions = true;
501 self
502 }
503
504 #[must_use]
514 pub fn session_id(mut self, id: impl Into<String>) -> Self {
515 self.shared.session_id = Some(id.into());
516 self
517 }
518
519 #[must_use]
527 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
528 self.shared.json_schema = Some(schema.into());
529 self
530 }
531
532 #[must_use]
540 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
541 where
542 I: IntoIterator<Item = T>,
543 T: Into<ToolPattern>,
544 {
545 self.shared
546 .allowed_tools
547 .extend(tools.into_iter().map(Into::into));
548 self
549 }
550
551 #[must_use]
553 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
554 self.shared.allowed_tools.push(tool.into());
555 self
556 }
557
558 #[must_use]
560 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
561 where
562 I: IntoIterator<Item = T>,
563 T: Into<ToolPattern>,
564 {
565 self.shared
566 .disallowed_tools
567 .extend(tools.into_iter().map(Into::into));
568 self
569 }
570
571 #[must_use]
573 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
574 self.shared.disallowed_tools.push(tool.into());
575 self
576 }
577
578 #[must_use]
584 pub fn max_turns(mut self, turns: u32) -> Self {
585 self.shared.max_turns = Some(turns);
586 self
587 }
588
589 #[must_use]
599 pub fn max_budget_usd(mut self, budget: f64) -> Self {
600 self.shared.max_budget_usd = Some(budget);
601 self
602 }
603
604 #[must_use]
607 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
608 self.shared.fallback_model = Some(model.into());
609 self
610 }
611
612 #[must_use]
614 pub fn effort(mut self, effort: Effort) -> Self {
615 self.shared.effort = Some(effort);
616 self
617 }
618
619 #[must_use]
622 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
623 self.shared.add_dir.push(dir.into());
624 self
625 }
626
627 #[must_use]
632 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
633 self.shared.mcp_config.push(path.into());
634 self
635 }
636
637 #[must_use]
641 pub fn strict_mcp_config(mut self) -> Self {
642 self.shared.strict_mcp_config = true;
643 self
644 }
645
646 #[must_use]
652 pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
653 self.shared.setting_sources = Some(sources.into());
654 self
655 }
656
657 #[must_use]
670 pub fn hermetic(mut self) -> Self {
671 self.shared.apply_hermetic(HermeticScope::Full);
672 self
673 }
674
675 #[must_use]
681 pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
682 self.shared.apply_hermetic(scope);
683 self
684 }
685
686 #[must_use]
689 pub fn no_session_persistence(mut self) -> Self {
690 self.shared.no_session_persistence = true;
691 self
692 }
693
694 #[must_use]
702 pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
703 self.shared.tools.extend(tools.into_iter().map(Into::into));
704 self
705 }
706
707 #[must_use]
712 pub fn file(mut self, spec: impl Into<String>) -> Self {
713 self.shared.file.push(spec.into());
714 self
715 }
716
717 #[must_use]
721 pub fn settings(mut self, settings: impl Into<String>) -> Self {
722 self.shared.settings = Some(settings.into());
723 self
724 }
725
726 #[must_use]
733 pub fn fork_session(mut self) -> Self {
734 self.shared.fork_session = true;
735 self
736 }
737
738 #[must_use]
742 pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
743 self.shared.debug_filter = Some(filter.into());
744 self
745 }
746
747 #[must_use]
751 pub fn debug_file(mut self, path: impl Into<String>) -> Self {
752 self.shared.debug_file = Some(path.into());
753 self
754 }
755
756 #[must_use]
760 pub fn betas(mut self, betas: impl Into<String>) -> Self {
761 self.shared.betas = Some(betas.into());
762 self
763 }
764
765 #[must_use]
769 pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
770 self.shared.plugin_dirs.push(dir.into());
771 self
772 }
773
774 #[must_use]
778 pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
779 self.shared.plugin_urls.push(url.into());
780 self
781 }
782
783 #[must_use]
787 pub fn tmux(mut self) -> Self {
788 self.shared.tmux = true;
789 self
790 }
791
792 #[must_use]
800 pub fn bare(mut self) -> Self {
801 self.shared.bare = true;
802 self
803 }
804
805 #[must_use]
812 pub fn safe_mode(mut self) -> Self {
813 self.shared.safe_mode = true;
814 self
815 }
816
817 #[must_use]
822 pub fn disable_slash_commands(mut self) -> Self {
823 self.shared.disable_slash_commands = true;
824 self
825 }
826
827 #[must_use]
834 pub fn include_hook_events(mut self) -> Self {
835 self.shared.include_hook_events = true;
836 self
837 }
838
839 #[must_use]
848 pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
849 self.shared.exclude_dynamic_system_prompt_sections = true;
850 self
851 }
852
853 #[must_use]
857 pub fn name(mut self, name: impl Into<String>) -> Self {
858 self.shared.name = Some(name.into());
859 self
860 }
861
862 #[must_use]
867 pub fn arg(mut self, arg: impl Into<String>) -> Self {
868 self.additional_args.push(arg.into());
869 self
870 }
871
872 #[must_use]
880 pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
881 self.subscriber_capacity = Some(capacity);
882 self
883 }
884
885 #[must_use]
903 pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
904 self.on_permission = Some(handler);
905 self
906 }
907
908 fn into_args(self) -> Vec<String> {
909 let mut args = vec![
910 "--print".to_string(),
911 "--verbose".to_string(),
912 "--output-format".to_string(),
913 "stream-json".to_string(),
914 "--input-format".to_string(),
915 "stream-json".to_string(),
916 ];
917
918 self.shared.append_to(&mut args);
919
920 if self.on_permission.is_some() {
921 args.push("--permission-prompt-tool".to_string());
922 args.push("stdio".to_string());
923 }
924 args.extend(self.additional_args);
925
926 args
927 }
928}
929
930#[derive(Debug, Clone)]
937pub struct TurnResult {
938 pub result: Value,
940 pub events: Vec<Value>,
942}
943
944impl TurnResult {
945 #[must_use]
947 pub fn result_text(&self) -> Option<&str> {
948 self.result.get("result").and_then(Value::as_str)
949 }
950
951 #[must_use]
953 pub fn session_id(&self) -> Option<&str> {
954 self.result.get("session_id").and_then(Value::as_str)
955 }
956
957 #[must_use]
964 pub fn total_cost_usd(&self) -> Option<f64> {
965 self.result
966 .get("total_cost_usd")
967 .or_else(|| self.result.get("cost_usd"))
968 .and_then(Value::as_f64)
969 }
970
971 #[must_use]
973 pub fn duration_ms(&self) -> Option<u64> {
974 self.result.get("duration_ms").and_then(Value::as_u64)
975 }
976}
977
978#[derive(Debug, Clone)]
992pub enum InboundEvent {
993 SystemInit {
996 session_id: String,
999 },
1000 Assistant(Value),
1003 StreamEvent(Value),
1006 User(Value),
1009 Other(Value),
1012}
1013
1014fn classify(msg: &Value) -> InboundEvent {
1015 match msg.get("type").and_then(Value::as_str) {
1016 Some("system") => {
1017 if msg.get("subtype").and_then(Value::as_str) == Some("init")
1018 && let Some(id) = msg.get("session_id").and_then(Value::as_str)
1019 {
1020 return InboundEvent::SystemInit {
1021 session_id: id.to_string(),
1022 };
1023 }
1024 InboundEvent::Other(msg.clone())
1025 }
1026 Some("assistant") => InboundEvent::Assistant(msg.clone()),
1027 Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
1028 Some("user") => InboundEvent::User(msg.clone()),
1029 _ => InboundEvent::Other(msg.clone()),
1030 }
1031}
1032
1033#[derive(Debug, Clone)]
1048pub enum SessionExitStatus {
1049 Running,
1051 Completed,
1054 Failed(String),
1057}
1058
1059#[derive(Debug)]
1068pub struct DuplexSession {
1069 outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
1070 events_tx: broadcast::Sender<InboundEvent>,
1071 exit_rx: watch::Receiver<SessionExitStatus>,
1072 join: JoinHandle<Result<()>>,
1073}
1074
1075#[derive(Debug)]
1076enum OutboundMsg {
1077 Send {
1078 prompt: String,
1079 reply: oneshot::Sender<Result<TurnResult>>,
1080 },
1081 PermissionResponse {
1082 request_id: String,
1083 decision: PermissionDecision,
1084 },
1085 Interrupt {
1086 reply: oneshot::Sender<Result<()>>,
1087 },
1088}
1089
1090impl DuplexSession {
1091 pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
1099 let capacity = opts
1100 .subscriber_capacity
1101 .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
1102 let permission_handler = opts.on_permission.clone();
1103
1104 let mut command_args = Vec::new();
1105 command_args.extend(claude.global_args.clone());
1106 command_args.extend(opts.into_args());
1107
1108 debug!(
1109 binary = %claude.binary.display(),
1110 args = ?command_args,
1111 "spawning duplex claude session"
1112 );
1113
1114 let mut cmd = Command::new(&claude.binary);
1115 cmd.args(&command_args)
1116 .env_remove("CLAUDECODE")
1117 .env_remove("CLAUDE_CODE_ENTRYPOINT")
1118 .envs(&claude.env)
1119 .stdin(Stdio::piped())
1120 .stdout(Stdio::piped())
1121 .stderr(Stdio::piped())
1122 .kill_on_drop(true);
1123
1124 if let Some(ref dir) = claude.working_dir {
1125 cmd.current_dir(dir);
1126 }
1127
1128 let mut child = cmd.spawn().map_err(|e| Error::Io {
1129 message: format!("failed to spawn claude: {e}"),
1130 source: e,
1131 working_dir: claude.working_dir.clone(),
1132 })?;
1133
1134 let stdin = child.stdin.take().expect("stdin was piped");
1135 let stdout = child.stdout.take().expect("stdout was piped");
1136
1137 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
1138 let (events_tx, _initial_rx) = broadcast::channel(capacity);
1139 let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
1140
1141 let join = tokio::spawn(run_session(
1142 child,
1143 stdin,
1144 stdout,
1145 outbound_rx,
1146 events_tx.clone(),
1147 permission_handler,
1148 exit_tx,
1149 ));
1150
1151 Ok(Self {
1152 outbound_tx,
1153 events_tx,
1154 exit_rx,
1155 join,
1156 })
1157 }
1158
1159 pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
1170 let (reply_tx, reply_rx) = oneshot::channel();
1171 self.outbound_tx
1172 .send(OutboundMsg::Send {
1173 prompt: prompt.into(),
1174 reply: reply_tx,
1175 })
1176 .map_err(|_| Error::DuplexClosed)?;
1177 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1178 }
1179
1180 #[must_use]
1215 pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
1216 self.events_tx.subscribe()
1217 }
1218
1219 #[must_use]
1230 pub fn is_alive(&self) -> bool {
1231 matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1232 }
1233
1234 #[must_use]
1243 pub fn exit_status(&self) -> SessionExitStatus {
1244 self.exit_rx.borrow().clone()
1245 }
1246
1247 pub async fn wait_for_exit(&self) -> SessionExitStatus {
1259 let mut rx = self.exit_rx.clone();
1260 loop {
1261 {
1262 let value = rx.borrow_and_update();
1263 if !matches!(*value, SessionExitStatus::Running) {
1264 return value.clone();
1265 }
1266 }
1267 if rx.changed().await.is_err() {
1268 return rx.borrow().clone();
1269 }
1270 }
1271 }
1272
1273 pub fn respond_to_permission(
1318 &self,
1319 request_id: impl Into<String>,
1320 decision: PermissionDecision,
1321 ) -> Result<()> {
1322 if matches!(decision, PermissionDecision::Defer) {
1323 warn!("respond_to_permission called with Defer; ignoring");
1324 return Ok(());
1325 }
1326 self.outbound_tx
1327 .send(OutboundMsg::PermissionResponse {
1328 request_id: request_id.into(),
1329 decision,
1330 })
1331 .map_err(|_| Error::DuplexClosed)?;
1332 Ok(())
1333 }
1334
1335 pub async fn interrupt(&self) -> Result<()> {
1376 let (reply_tx, reply_rx) = oneshot::channel();
1377 self.outbound_tx
1378 .send(OutboundMsg::Interrupt { reply: reply_tx })
1379 .map_err(|_| Error::DuplexClosed)?;
1380 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1381 }
1382
1383 pub async fn close(self) -> Result<()> {
1389 drop(self.outbound_tx);
1390 drop(self.events_tx);
1391 match self.join.await {
1392 Ok(result) => result,
1393 Err(e) if e.is_cancelled() => Ok(()),
1394 Err(e) => Err(Error::Io {
1395 message: format!("duplex session task panicked: {e}"),
1396 source: std::io::Error::other(e.to_string()),
1397 working_dir: None,
1398 }),
1399 }
1400 }
1401}
1402
1403const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1407
1408async fn run_session(
1409 mut child: Child,
1410 mut stdin: ChildStdin,
1411 stdout: ChildStdout,
1412 mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1413 events_tx: broadcast::Sender<InboundEvent>,
1414 permission_handler: Option<PermissionHandler>,
1415 exit_tx: watch::Sender<SessionExitStatus>,
1416) -> Result<()> {
1417 let mut lines = BufReader::new(stdout).lines();
1418 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1419 let mut pending_control: HashMap<String, oneshot::Sender<Result<()>>> = HashMap::new();
1420 let mut next_control_id: u64 = 0;
1421 let mut stream_err: Option<Error> = None;
1422
1423 loop {
1424 tokio::select! {
1425 biased;
1426
1427 line = lines.next_line() => match line {
1428 Ok(Some(l)) => {
1429 if l.trim().is_empty() {
1430 continue;
1431 }
1432 let parsed = match serde_json::from_str::<Value>(&l) {
1433 Ok(v) => v,
1434 Err(e) => {
1435 debug!(line = %l, error = %e, "failed to parse duplex event, skipping");
1436 continue;
1437 }
1438 };
1439 match handle_inbound(parsed, &mut pending, &events_tx) {
1440 InboundAction::None => {}
1441 InboundAction::Permission(req) => {
1442 let request_id = req.request_id.clone();
1443 let decision = match permission_handler.as_ref() {
1444 Some(h) => h.invoke(req).await,
1445 None => {
1446 warn!(
1447 request_id = %request_id,
1448 "received can_use_tool with no permission handler; auto-denying"
1449 );
1450 PermissionDecision::Deny {
1451 message:
1452 "no permission handler configured on duplex session"
1453 .into(),
1454 }
1455 }
1456 };
1457 if matches!(decision, PermissionDecision::Defer) {
1458 debug!(
1459 request_id = %request_id,
1460 "permission handler deferred; waiting for respond_to_permission"
1461 );
1462 } else if let Err(e) =
1463 write_permission_response(&mut stdin, &request_id, &decision).await
1464 {
1465 warn!(error = %e, "failed to write permission response");
1466 }
1467 }
1468 InboundAction::ControlResponse { request_id, outcome } => {
1469 if let Some(reply) = pending_control.remove(&request_id) {
1470 let _ = reply.send(outcome);
1471 } else {
1472 debug!(
1473 request_id = %request_id,
1474 "received control_response with no pending request"
1475 );
1476 }
1477 }
1478 }
1479 }
1480 Ok(None) => break,
1481 Err(e) => {
1482 stream_err = Some(Error::Io {
1483 message: "failed to read duplex stdout".to_string(),
1484 source: e,
1485 working_dir: None,
1486 });
1487 break;
1488 }
1489 },
1490
1491 msg = outbound_rx.recv() => match msg {
1492 Some(OutboundMsg::Send { prompt, reply }) => {
1493 if pending.is_some() {
1494 let _ = reply.send(Err(Error::DuplexTurnInFlight));
1495 continue;
1496 }
1497 if let Err(e) = write_user(&mut stdin, &prompt).await {
1498 let _ = reply.send(Err(e));
1499 continue;
1500 }
1501 pending = Some((reply, Vec::new()));
1502 }
1503 Some(OutboundMsg::PermissionResponse { request_id, decision }) => {
1504 if let Err(e) =
1505 write_permission_response(&mut stdin, &request_id, &decision).await
1506 {
1507 warn!(error = %e, "failed to write deferred permission response");
1508 }
1509 }
1510 Some(OutboundMsg::Interrupt { reply }) => {
1511 next_control_id += 1;
1512 let request_id = format!("interrupt-{next_control_id}");
1513 if let Err(e) =
1514 write_control_request(&mut stdin, &request_id, "interrupt").await
1515 {
1516 let _ = reply.send(Err(e));
1517 continue;
1518 }
1519 pending_control.insert(request_id, reply);
1520 }
1521 None => break,
1522 },
1523 }
1524 }
1525
1526 drop(stdin);
1527 match tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()).await {
1528 Ok(Ok(_status)) => {}
1529 Ok(Err(e)) => {
1530 warn!(error = %e, "failed to wait for duplex child");
1531 }
1532 Err(_) => {
1533 warn!("duplex child did not exit within shutdown budget; killing");
1534 let _ = child.kill().await;
1535 }
1536 }
1537
1538 if let Some((reply, _)) = pending.take() {
1539 let _ = reply.send(Err(Error::DuplexClosed));
1540 }
1541 for (_, reply) in pending_control.drain() {
1542 let _ = reply.send(Err(Error::DuplexClosed));
1543 }
1544
1545 let result = match stream_err {
1546 Some(e) => Err(e),
1547 None => Ok(()),
1548 };
1549 let final_state = match &result {
1550 Ok(()) => SessionExitStatus::Completed,
1551 Err(e) => SessionExitStatus::Failed(e.to_string()),
1552 };
1553 let _ = exit_tx.send(final_state);
1554 result
1555}
1556
1557enum InboundAction {
1561 None,
1563 Permission(PermissionRequest),
1567 ControlResponse {
1572 request_id: String,
1573 outcome: Result<()>,
1574 },
1575}
1576
1577fn handle_inbound(
1578 msg: Value,
1579 pending: &mut Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>,
1580 events_tx: &broadcast::Sender<InboundEvent>,
1581) -> InboundAction {
1582 match msg.get("type").and_then(Value::as_str) {
1583 Some("result") => {
1584 if let Some((reply, events)) = pending.take() {
1585 let _ = reply.send(Ok(TurnResult {
1586 result: msg,
1587 events,
1588 }));
1589 } else {
1590 debug!("dropping orphan result event with no pending turn");
1591 }
1592 InboundAction::None
1593 }
1594 Some("control_request") => {
1595 if msg
1598 .get("request")
1599 .and_then(|r| r.get("subtype"))
1600 .and_then(Value::as_str)
1601 == Some("can_use_tool")
1602 && let Some(req) = parse_permission_request(&msg)
1603 {
1604 if let Some((_, events)) = pending.as_mut() {
1605 events.push(msg);
1606 }
1607 return InboundAction::Permission(req);
1608 }
1609 debug!(
1610 ?msg,
1611 "received unhandled control_request; treating as Other"
1612 );
1613 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1614 if let Some((_, events)) = pending.as_mut() {
1615 events.push(msg);
1616 }
1617 InboundAction::None
1618 }
1619 Some("control_response") => {
1620 if let Some((request_id, outcome)) = parse_control_response(&msg) {
1621 return InboundAction::ControlResponse {
1622 request_id,
1623 outcome,
1624 };
1625 }
1626 debug!(
1627 ?msg,
1628 "received malformed control_response; treating as Other"
1629 );
1630 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1631 if let Some((_, events)) = pending.as_mut() {
1632 events.push(msg);
1633 }
1634 InboundAction::None
1635 }
1636 _ => {
1637 let _ = events_tx.send(classify(&msg));
1640
1641 if let Some((_, events)) = pending.as_mut() {
1642 events.push(msg);
1643 } else {
1644 debug!("dropping inbound event with no pending turn");
1645 }
1646 InboundAction::None
1647 }
1648 }
1649}
1650
1651fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
1652 let request_id = msg.get("request_id").and_then(Value::as_str)?;
1653 let request = msg.get("request")?;
1654 let tool_name = request.get("tool_name").and_then(Value::as_str)?;
1655 let input = request.get("input").cloned().unwrap_or(Value::Null);
1656 Some(PermissionRequest {
1657 request_id: request_id.to_string(),
1658 tool_name: tool_name.to_string(),
1659 input,
1660 raw: request.clone(),
1661 })
1662}
1663
1664fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
1670 let response = msg.get("response")?;
1671 let request_id = response.get("request_id").and_then(Value::as_str)?;
1672 let outcome = match response.get("subtype").and_then(Value::as_str) {
1673 Some("success") => Ok(()),
1674 Some("error") => {
1675 let message = response
1676 .get("error")
1677 .and_then(Value::as_str)
1678 .unwrap_or("unknown control_response error")
1679 .to_string();
1680 Err(Error::DuplexControlFailed { message })
1681 }
1682 _ => return None,
1683 };
1684 Some((request_id.to_string(), outcome))
1685}
1686
1687async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
1688 let user_msg = serde_json::json!({
1689 "type": "user",
1690 "message": {
1691 "role": "user",
1692 "content": prompt,
1693 },
1694 "parent_tool_use_id": null,
1695 });
1696 write_line(stdin, &user_msg, "user message").await
1697}
1698
1699async fn write_control_request(
1700 stdin: &mut ChildStdin,
1701 request_id: &str,
1702 subtype: &str,
1703) -> Result<()> {
1704 let envelope = serde_json::json!({
1705 "type": "control_request",
1706 "request_id": request_id,
1707 "request": { "subtype": subtype },
1708 });
1709 write_line(stdin, &envelope, "control_request").await
1710}
1711
1712async fn write_permission_response(
1713 stdin: &mut ChildStdin,
1714 request_id: &str,
1715 decision: &PermissionDecision,
1716) -> Result<()> {
1717 let inner = match decision {
1718 PermissionDecision::Allow { updated_input } => {
1719 let mut obj = serde_json::Map::new();
1720 obj.insert("behavior".to_string(), Value::String("allow".to_string()));
1721 if let Some(input) = updated_input {
1722 obj.insert("updatedInput".to_string(), input.clone());
1723 }
1724 Value::Object(obj)
1725 }
1726 PermissionDecision::Deny { message } => serde_json::json!({
1727 "behavior": "deny",
1728 "message": message,
1729 }),
1730 PermissionDecision::Defer => {
1731 return Ok(());
1733 }
1734 };
1735 let envelope = serde_json::json!({
1736 "type": "control_response",
1737 "response": {
1738 "request_id": request_id,
1739 "subtype": "success",
1740 "response": inner,
1741 },
1742 });
1743 write_line(stdin, &envelope, "control_response").await
1744}
1745
1746async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
1747 let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
1748 message: format!("failed to serialize duplex {what}"),
1749 source: e,
1750 })?;
1751 line.push('\n');
1752 stdin
1753 .write_all(line.as_bytes())
1754 .await
1755 .map_err(|e| Error::Io {
1756 message: format!("failed to write {what} to duplex stdin"),
1757 source: e,
1758 working_dir: None,
1759 })?;
1760 stdin.flush().await.map_err(|e| Error::Io {
1761 message: "failed to flush duplex stdin".to_string(),
1762 source: e,
1763 working_dir: None,
1764 })?;
1765 Ok(())
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770 use super::*;
1771 use serde_json::json;
1772
1773 #[test]
1774 fn into_args_default_includes_required_flags() {
1775 let args = DuplexOptions::default().into_args();
1776 assert!(args.contains(&"--print".to_string()));
1777 assert!(args.contains(&"--verbose".to_string()));
1778 assert!(
1779 args.windows(2)
1780 .any(|w| w == ["--output-format", "stream-json"])
1781 );
1782 assert!(
1783 args.windows(2)
1784 .any(|w| w == ["--input-format", "stream-json"])
1785 );
1786 }
1787
1788 #[test]
1789 fn into_args_includes_model() {
1790 let args = DuplexOptions::default().model("haiku").into_args();
1791 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1792 }
1793
1794 #[test]
1795 fn into_args_includes_system_prompts() {
1796 let args = DuplexOptions::default()
1797 .system_prompt("be concise")
1798 .append_system_prompt("also polite")
1799 .into_args();
1800 assert!(
1801 args.windows(2)
1802 .any(|w| w == ["--system-prompt", "be concise"])
1803 );
1804 assert!(
1805 args.windows(2)
1806 .any(|w| w == ["--append-system-prompt", "also polite"])
1807 );
1808 }
1809
1810 #[test]
1811 fn into_args_appends_raw_args_last() {
1812 let args = DuplexOptions::default()
1813 .arg("--add-dir")
1814 .arg("/tmp/foo")
1815 .into_args();
1816 assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
1818 }
1819
1820 #[test]
1821 fn into_args_includes_resume_when_set() {
1822 let args = DuplexOptions::default().resume("abc-123").into_args();
1823 assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
1824 }
1825
1826 #[test]
1827 fn into_args_omits_resume_by_default() {
1828 let args = DuplexOptions::default().into_args();
1829 assert!(
1830 !args.iter().any(|a| a == "--resume"),
1831 "--resume should not appear without an explicit resume(...) call; got {args:?}"
1832 );
1833 }
1834
1835 #[test]
1836 fn into_args_includes_continue_when_set() {
1837 let args = DuplexOptions::default().continue_session().into_args();
1838 assert!(args.iter().any(|a| a == "--continue"));
1839 }
1840
1841 #[test]
1842 fn into_args_omits_continue_by_default() {
1843 let args = DuplexOptions::default().into_args();
1844 assert!(!args.iter().any(|a| a == "--continue"));
1845 }
1846
1847 #[test]
1848 fn into_args_includes_worktree_flag_without_name() {
1849 let args = DuplexOptions::default().worktree(None::<&str>).into_args();
1850 assert!(args.iter().any(|a| a == "--worktree"));
1851 let pos = args.iter().position(|a| a == "--worktree").unwrap();
1853 assert!(
1854 args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
1855 "--worktree without a name should not be followed by a positional; got {args:?}"
1856 );
1857 }
1858
1859 #[test]
1860 fn into_args_includes_worktree_flag_with_name() {
1861 let args = DuplexOptions::default()
1862 .worktree(Some("agent-xyz"))
1863 .into_args();
1864 let pos = args.iter().position(|a| a == "--worktree").unwrap();
1865 assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
1866 }
1867
1868 #[test]
1869 fn into_args_omits_worktree_by_default() {
1870 let args = DuplexOptions::default().into_args();
1871 assert!(
1872 !args.iter().any(|a| a == "--worktree"),
1873 "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
1874 );
1875 }
1876
1877 #[test]
1878 fn worktree_lands_before_additional_args() {
1879 let args = DuplexOptions::default()
1881 .worktree(Some("foo"))
1882 .arg("--")
1883 .arg("trailing")
1884 .into_args();
1885 let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
1886 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1887 assert!(
1888 wt_pos < dash_dash_pos,
1889 "--worktree must precede `--` separator; got {args:?}"
1890 );
1891 }
1892
1893 #[test]
1894 fn into_args_includes_agent_when_set() {
1895 let args = DuplexOptions::default().agent("rust-qa").into_args();
1896 assert!(
1897 args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
1898 "missing --agent rust-qa in {args:?}"
1899 );
1900 }
1901
1902 #[test]
1903 fn into_args_omits_agent_by_default() {
1904 let args = DuplexOptions::default().into_args();
1905 assert!(
1906 !args.iter().any(|a| a == "--agent"),
1907 "--agent should not appear without an explicit agent(...) call; got {args:?}"
1908 );
1909 }
1910
1911 #[test]
1912 fn into_args_includes_agents_json_when_set() {
1913 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
1914 let args = DuplexOptions::default().agents_json(json).into_args();
1915 let pos = args.iter().position(|a| a == "--agents").unwrap();
1916 assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
1917 }
1918
1919 #[test]
1920 fn into_args_omits_agents_json_by_default() {
1921 let args = DuplexOptions::default().into_args();
1922 assert!(!args.iter().any(|a| a == "--agents"));
1923 }
1924
1925 #[test]
1926 fn agent_and_agents_json_compose() {
1927 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
1928 let args = DuplexOptions::default()
1929 .agents_json(json)
1930 .agent("reviewer")
1931 .into_args();
1932 assert!(args.iter().any(|a| a == "--agents"));
1934 assert!(args.iter().any(|a| a == "--agent"));
1935 }
1936
1937 #[test]
1938 fn agent_lands_before_additional_args() {
1939 let args = DuplexOptions::default()
1940 .agent("rust-qa")
1941 .arg("--")
1942 .arg("trailing")
1943 .into_args();
1944 let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
1945 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1946 assert!(
1947 agent_pos < dash_dash_pos,
1948 "--agent must precede `--` separator; got {args:?}"
1949 );
1950 }
1951
1952 #[test]
1953 fn agents_json_lands_before_additional_args() {
1954 let args = DuplexOptions::default()
1955 .agents_json("{}")
1956 .arg("--")
1957 .arg("trailing")
1958 .into_args();
1959 let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
1960 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1961 assert!(
1962 agents_pos < dash_dash_pos,
1963 "--agents must precede `--` separator; got {args:?}"
1964 );
1965 }
1966
1967 #[test]
1970 fn into_args_includes_session_id() {
1971 let args = DuplexOptions::default().session_id("sid-9").into_args();
1972 assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
1973 }
1974
1975 #[test]
1976 fn into_args_includes_setting_sources() {
1977 let args = DuplexOptions::default()
1978 .setting_sources("user,project")
1979 .into_args();
1980 assert!(
1981 args.windows(2)
1982 .any(|w| w == ["--setting-sources", "user,project"]),
1983 "got {args:?}"
1984 );
1985 }
1986
1987 #[test]
1988 fn into_args_omits_setting_sources_by_default() {
1989 let args = DuplexOptions::default().into_args();
1990 assert!(!args.iter().any(|a| a == "--setting-sources"));
1991 }
1992
1993 #[test]
1994 fn into_args_hermetic_emits_full_seal() {
1995 let args = DuplexOptions::default().hermetic().into_args();
1996 assert!(
1997 args.windows(2)
1998 .any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
1999 "got {args:?}"
2000 );
2001 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2002 assert!(
2003 args.iter()
2004 .any(|a| a == "--exclude-dynamic-system-prompt-sections")
2005 );
2006 assert!(!args.iter().any(|a| a == "--bare"));
2008 }
2009
2010 #[test]
2011 fn into_args_hermetic_scoped_project_keeps_user() {
2012 let args = DuplexOptions::default()
2013 .hermetic_scoped(HermeticScope::Project)
2014 .into_args();
2015 assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
2016 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2017 }
2018
2019 #[test]
2020 fn into_args_includes_json_schema() {
2021 let schema = r#"{"type":"object"}"#;
2022 let args = DuplexOptions::default().json_schema(schema).into_args();
2023 assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
2024 }
2025
2026 #[test]
2027 fn into_args_joins_allowed_tools_comma_separated() {
2028 let args = DuplexOptions::default()
2029 .allowed_tools(["Read", "Bash(git log:*)"])
2030 .allowed_tool("Write")
2031 .into_args();
2032 assert!(
2033 args.windows(2)
2034 .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
2035 "missing joined --allowed-tools in {args:?}"
2036 );
2037 }
2038
2039 #[test]
2040 fn into_args_joins_disallowed_tools_comma_separated() {
2041 let args = DuplexOptions::default()
2042 .disallowed_tools(["WebSearch"])
2043 .disallowed_tool("WebFetch")
2044 .into_args();
2045 assert!(
2046 args.windows(2)
2047 .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
2048 "missing joined --disallowed-tools in {args:?}"
2049 );
2050 }
2051
2052 #[test]
2053 fn into_args_includes_caps() {
2054 let args = DuplexOptions::default()
2055 .max_turns(4)
2056 .max_budget_usd(0.25)
2057 .into_args();
2058 assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
2059 assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
2060 }
2061
2062 #[test]
2063 fn into_args_includes_fallback_model_and_effort() {
2064 let args = DuplexOptions::default()
2065 .fallback_model("haiku")
2066 .effort(Effort::Low)
2067 .into_args();
2068 assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
2069 assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
2070 }
2071
2072 #[test]
2073 fn into_args_repeats_add_dir_and_mcp_config() {
2074 let args = DuplexOptions::default()
2075 .add_dir("/a")
2076 .add_dir("/b")
2077 .mcp_config("x.json")
2078 .strict_mcp_config()
2079 .into_args();
2080 assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
2081 assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
2082 assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
2083 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
2084 }
2085
2086 #[test]
2087 fn into_args_includes_no_session_persistence() {
2088 let args = DuplexOptions::default()
2089 .no_session_persistence()
2090 .into_args();
2091 assert!(args.iter().any(|a| a == "--no-session-persistence"));
2092 }
2093
2094 #[test]
2097 fn into_args_joins_tools_comma_separated() {
2098 let args = DuplexOptions::default()
2099 .tools(["Bash", "Read", "Edit"])
2100 .into_args();
2101 assert!(
2102 args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
2103 "missing joined --tools in {args:?}"
2104 );
2105 }
2106
2107 #[test]
2108 fn into_args_repeats_file_per_spec() {
2109 let args = DuplexOptions::default()
2110 .file("file_a:doc.txt")
2111 .file("file_b:notes.md")
2112 .into_args();
2113 assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
2114 assert!(args.iter().any(|a| a == "file_a:doc.txt"));
2115 assert!(args.iter().any(|a| a == "file_b:notes.md"));
2116 }
2117
2118 #[test]
2119 fn into_args_includes_settings() {
2120 let args = DuplexOptions::default()
2121 .settings("/tmp/settings.json")
2122 .into_args();
2123 assert!(
2124 args.windows(2)
2125 .any(|w| w == ["--settings", "/tmp/settings.json"])
2126 );
2127 }
2128
2129 #[test]
2130 fn into_args_includes_fork_session() {
2131 let args = DuplexOptions::default().fork_session().into_args();
2132 assert!(args.iter().any(|a| a == "--fork-session"));
2133 }
2134
2135 #[test]
2136 fn into_args_includes_debug_filter_and_file() {
2137 let args = DuplexOptions::default()
2138 .debug_filter("api,hooks")
2139 .debug_file("/tmp/debug.log")
2140 .into_args();
2141 assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
2142 assert!(
2143 args.windows(2)
2144 .any(|w| w == ["--debug-file", "/tmp/debug.log"])
2145 );
2146 }
2147
2148 #[test]
2149 fn into_args_includes_betas() {
2150 let args = DuplexOptions::default().betas("feature-x").into_args();
2151 assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
2152 }
2153
2154 #[test]
2155 fn into_args_repeats_plugin_dir_and_url() {
2156 let args = DuplexOptions::default()
2157 .plugin_dir("/plugins/a")
2158 .plugin_dir("/plugins/b")
2159 .plugin_url("https://example.com/p.zip")
2160 .into_args();
2161 assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
2162 assert!(
2163 args.windows(2)
2164 .any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
2165 );
2166 }
2167
2168 #[test]
2169 fn into_args_includes_bare_family_bool_flags() {
2170 let args = DuplexOptions::default()
2171 .tmux()
2172 .bare()
2173 .safe_mode()
2174 .disable_slash_commands()
2175 .include_hook_events()
2176 .exclude_dynamic_system_prompt_sections()
2177 .into_args();
2178 for flag in [
2179 "--tmux",
2180 "--bare",
2181 "--safe-mode",
2182 "--disable-slash-commands",
2183 "--include-hook-events",
2184 "--exclude-dynamic-system-prompt-sections",
2185 ] {
2186 assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
2187 }
2188 }
2189
2190 #[test]
2191 fn into_args_includes_name() {
2192 let args = DuplexOptions::default().name("my session").into_args();
2193 assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
2194 }
2195
2196 #[test]
2197 fn into_args_omits_promoted_parity_flags_by_default() {
2198 let args = DuplexOptions::default().into_args();
2199 for flag in [
2200 "--tools",
2201 "--file",
2202 "--settings",
2203 "--fork-session",
2204 "--debug",
2205 "--debug-file",
2206 "--betas",
2207 "--plugin-dir",
2208 "--plugin-url",
2209 "--tmux",
2210 "--bare",
2211 "--safe-mode",
2212 "--disable-slash-commands",
2213 "--include-hook-events",
2214 "--exclude-dynamic-system-prompt-sections",
2215 "--name",
2216 ] {
2217 assert!(
2218 !args.iter().any(|a| a == flag),
2219 "{flag} should be absent by default; got {args:?}"
2220 );
2221 }
2222 }
2223
2224 #[test]
2225 fn parity_flags_land_before_additional_args() {
2226 let args = DuplexOptions::default()
2228 .max_turns(2)
2229 .json_schema("{}")
2230 .arg("--")
2231 .arg("trailing")
2232 .into_args();
2233 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2234 for flag in ["--max-turns", "--json-schema"] {
2235 let pos = args.iter().position(|a| a == flag).unwrap();
2236 assert!(
2237 pos < dash_dash_pos,
2238 "{flag} must precede `--` separator; got {args:?}"
2239 );
2240 }
2241 }
2242
2243 #[test]
2244 fn into_args_omits_parity_flags_by_default() {
2245 let args = DuplexOptions::default().into_args();
2246 for flag in [
2247 "--session-id",
2248 "--json-schema",
2249 "--allowed-tools",
2250 "--disallowed-tools",
2251 "--max-turns",
2252 "--max-budget-usd",
2253 "--fallback-model",
2254 "--effort",
2255 "--add-dir",
2256 "--mcp-config",
2257 "--strict-mcp-config",
2258 "--no-session-persistence",
2259 ] {
2260 assert!(
2261 !args.iter().any(|a| a == flag),
2262 "{flag} should not appear by default; got {args:?}"
2263 );
2264 }
2265 }
2266
2267 #[test]
2268 fn resume_lands_before_additional_args() {
2269 let args = DuplexOptions::default()
2274 .resume("xyz")
2275 .arg("--")
2276 .arg("trailing")
2277 .into_args();
2278 let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
2279 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
2280 assert!(
2281 resume_pos < dash_dash_pos,
2282 "--resume must precede `--` separator; got {args:?}"
2283 );
2284 }
2285
2286 #[test]
2287 fn turn_result_accessors_pull_from_result() {
2288 let r = TurnResult {
2289 result: json!({
2290 "type": "result",
2291 "result": "hello",
2292 "session_id": "sess-123",
2293 "total_cost_usd": 0.0042,
2294 "duration_ms": 1234_u64,
2295 }),
2296 events: vec![],
2297 };
2298 assert_eq!(r.result_text(), Some("hello"));
2299 assert_eq!(r.session_id(), Some("sess-123"));
2300 assert_eq!(r.total_cost_usd(), Some(0.0042));
2301 assert_eq!(r.duration_ms(), Some(1234));
2302 }
2303
2304 #[test]
2305 fn turn_result_total_cost_falls_back_to_legacy_field() {
2306 let r = TurnResult {
2307 result: json!({ "cost_usd": 0.5 }),
2308 events: vec![],
2309 };
2310 assert_eq!(r.total_cost_usd(), Some(0.5));
2311 }
2312
2313 #[test]
2314 fn turn_result_accessors_return_none_when_missing() {
2315 let r = TurnResult {
2316 result: json!({}),
2317 events: vec![],
2318 };
2319 assert_eq!(r.result_text(), None);
2320 assert_eq!(r.session_id(), None);
2321 assert_eq!(r.total_cost_usd(), None);
2322 assert_eq!(r.duration_ms(), None);
2323 }
2324
2325 #[test]
2326 fn handle_inbound_appends_non_result_to_pending_events() {
2327 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2328 let (events_tx, _events_rx) = broadcast::channel(16);
2329 let mut pending = Some((tx, Vec::new()));
2330 handle_inbound(
2331 json!({ "type": "assistant", "message": {} }),
2332 &mut pending,
2333 &events_tx,
2334 );
2335 let (_, events) = pending.as_ref().unwrap();
2336 assert_eq!(events.len(), 1);
2337 assert_eq!(
2338 events[0].get("type").and_then(Value::as_str),
2339 Some("assistant")
2340 );
2341 }
2342
2343 #[test]
2344 fn handle_inbound_resolves_pending_on_result() {
2345 let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
2346 let (events_tx, _events_rx) = broadcast::channel(16);
2347 let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
2348 handle_inbound(
2349 json!({ "type": "result", "result": "ok" }),
2350 &mut pending,
2351 &events_tx,
2352 );
2353 assert!(pending.is_none());
2354 let received = rx.blocking_recv().unwrap().unwrap();
2355 assert_eq!(received.result_text(), Some("ok"));
2356 assert_eq!(received.events.len(), 1);
2357 }
2358
2359 #[test]
2360 fn handle_inbound_drops_orphans_without_pending_turn() {
2361 let (events_tx, _events_rx) = broadcast::channel(16);
2362 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
2363 handle_inbound(json!({ "type": "assistant" }), &mut pending, &events_tx);
2364 handle_inbound(
2365 json!({ "type": "result", "result": "ok" }),
2366 &mut pending,
2367 &events_tx,
2368 );
2369 assert!(pending.is_none());
2370 }
2371
2372 #[test]
2373 fn handle_inbound_broadcasts_classified_event() {
2374 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2375 let (events_tx, mut events_rx) = broadcast::channel(16);
2376 let mut pending = Some((tx, Vec::new()));
2377 handle_inbound(
2378 json!({ "type": "assistant", "message": { "role": "assistant" } }),
2379 &mut pending,
2380 &events_tx,
2381 );
2382 let event = events_rx.try_recv().expect("classified event broadcast");
2383 assert!(matches!(event, InboundEvent::Assistant(_)));
2384 }
2385
2386 #[test]
2387 fn handle_inbound_does_not_broadcast_result() {
2388 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2389 let (events_tx, mut events_rx) = broadcast::channel(16);
2390 let mut pending = Some((tx, Vec::new()));
2391 handle_inbound(
2392 json!({ "type": "result", "result": "ok" }),
2393 &mut pending,
2394 &events_tx,
2395 );
2396 assert!(events_rx.try_recv().is_err());
2398 }
2399
2400 #[test]
2401 fn classify_system_init_pulls_session_id() {
2402 let v = json!({
2403 "type": "system",
2404 "subtype": "init",
2405 "session_id": "sess-abc",
2406 });
2407 match classify(&v) {
2408 InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
2409 other => panic!("expected SystemInit, got {other:?}"),
2410 }
2411 }
2412
2413 #[test]
2414 fn classify_system_without_init_subtype_is_other() {
2415 let v = json!({ "type": "system", "subtype": "compaction" });
2416 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2417 }
2418
2419 #[test]
2420 fn classify_system_init_without_session_id_is_other() {
2421 let v = json!({ "type": "system", "subtype": "init" });
2422 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2423 }
2424
2425 #[test]
2426 fn classify_assistant_stream_event_user() {
2427 assert!(matches!(
2428 classify(&json!({ "type": "assistant" })),
2429 InboundEvent::Assistant(_)
2430 ));
2431 assert!(matches!(
2432 classify(&json!({ "type": "stream_event" })),
2433 InboundEvent::StreamEvent(_)
2434 ));
2435 assert!(matches!(
2436 classify(&json!({ "type": "user" })),
2437 InboundEvent::User(_)
2438 ));
2439 }
2440
2441 #[test]
2442 fn classify_unknown_type_is_other() {
2443 assert!(matches!(
2444 classify(&json!({ "type": "control_request" })),
2445 InboundEvent::Other(_)
2446 ));
2447 assert!(matches!(
2448 classify(&json!({ "type": "future_thing" })),
2449 InboundEvent::Other(_)
2450 ));
2451 assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
2452 }
2453
2454 #[test]
2455 fn into_args_does_not_emit_subscriber_capacity_flag() {
2456 let args = DuplexOptions::default().subscriber_capacity(64).into_args();
2458 assert!(!args.iter().any(|a| a.contains("subscriber")));
2459 assert!(!args.iter().any(|a| a.contains("capacity")));
2460 }
2461
2462 #[test]
2463 fn into_args_includes_permission_prompt_tool_when_handler_set() {
2464 let handler = PermissionHandler::new(|_req| async move {
2465 PermissionDecision::Allow {
2466 updated_input: None,
2467 }
2468 });
2469 let args = DuplexOptions::default().on_permission(handler).into_args();
2470 assert!(
2471 args.windows(2)
2472 .any(|w| w == ["--permission-prompt-tool", "stdio"])
2473 );
2474 }
2475
2476 #[test]
2477 fn into_args_omits_permission_prompt_tool_without_handler() {
2478 let args = DuplexOptions::default().into_args();
2479 assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
2480 }
2481
2482 #[test]
2483 fn into_args_emits_permission_mode_flag() {
2484 let args = DuplexOptions::default()
2485 .permission_mode(PermissionMode::AcceptEdits)
2486 .into_args();
2487 assert!(
2488 args.windows(2)
2489 .any(|w| w == ["--permission-mode", "acceptEdits"]),
2490 "missing --permission-mode acceptEdits in {args:?}"
2491 );
2492 }
2493
2494 #[test]
2495 fn into_args_emits_plan_mode() {
2496 let args = DuplexOptions::default()
2497 .permission_mode(PermissionMode::Plan)
2498 .into_args();
2499 assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
2500 }
2501
2502 #[test]
2503 fn into_args_omits_permission_mode_by_default() {
2504 let args = DuplexOptions::default().into_args();
2505 assert!(!args.iter().any(|a| a == "--permission-mode"));
2506 }
2507
2508 #[test]
2509 fn into_args_emits_dangerously_skip_permissions_flag() {
2510 let args = DuplexOptions::default()
2511 .dangerously_skip_permissions()
2512 .into_args();
2513 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
2514 }
2515
2516 #[test]
2517 fn into_args_omits_dangerously_skip_by_default() {
2518 let args = DuplexOptions::default().into_args();
2519 assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
2520 }
2521
2522 #[test]
2523 fn parse_permission_request_extracts_fields() {
2524 let msg = json!({
2525 "type": "control_request",
2526 "request_id": "req-1",
2527 "request": {
2528 "subtype": "can_use_tool",
2529 "tool_name": "Bash",
2530 "input": { "command": "ls" }
2531 }
2532 });
2533 let req = parse_permission_request(&msg).expect("permission request");
2534 assert_eq!(req.request_id, "req-1");
2535 assert_eq!(req.tool_name, "Bash");
2536 assert_eq!(req.input, json!({ "command": "ls" }));
2537 assert_eq!(
2538 req.raw.get("subtype").and_then(Value::as_str),
2539 Some("can_use_tool")
2540 );
2541 }
2542
2543 #[test]
2544 fn parse_permission_request_returns_none_when_missing_request_id() {
2545 let msg = json!({
2546 "type": "control_request",
2547 "request": {
2548 "subtype": "can_use_tool",
2549 "tool_name": "Bash",
2550 }
2551 });
2552 assert!(parse_permission_request(&msg).is_none());
2553 }
2554
2555 #[test]
2556 fn parse_permission_request_returns_none_when_missing_tool_name() {
2557 let msg = json!({
2558 "type": "control_request",
2559 "request_id": "req-1",
2560 "request": { "subtype": "can_use_tool" }
2561 });
2562 assert!(parse_permission_request(&msg).is_none());
2563 }
2564
2565 #[test]
2566 fn parse_permission_request_handles_missing_input() {
2567 let msg = json!({
2568 "type": "control_request",
2569 "request_id": "req-1",
2570 "request": {
2571 "subtype": "can_use_tool",
2572 "tool_name": "Bash",
2573 }
2574 });
2575 let req = parse_permission_request(&msg).expect("request");
2576 assert_eq!(req.input, Value::Null);
2577 }
2578
2579 #[test]
2580 fn handle_inbound_returns_permission_for_can_use_tool() {
2581 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2582 let (events_tx, _events_rx) = broadcast::channel(16);
2583 let mut pending = Some((tx, Vec::new()));
2584 let action = handle_inbound(
2585 json!({
2586 "type": "control_request",
2587 "request_id": "req-1",
2588 "request": {
2589 "subtype": "can_use_tool",
2590 "tool_name": "Bash",
2591 "input": { "command": "ls" }
2592 }
2593 }),
2594 &mut pending,
2595 &events_tx,
2596 );
2597 match action {
2598 InboundAction::Permission(req) => {
2599 assert_eq!(req.request_id, "req-1");
2600 assert_eq!(req.tool_name, "Bash");
2601 }
2602 InboundAction::None | InboundAction::ControlResponse { .. } => {
2603 panic!("expected Permission action");
2604 }
2605 }
2606 let (_, events) = pending.as_ref().unwrap();
2608 assert_eq!(events.len(), 1);
2609 }
2610
2611 #[test]
2612 fn handle_inbound_treats_unknown_control_request_as_other() {
2613 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2614 let (events_tx, mut events_rx) = broadcast::channel(16);
2615 let mut pending = Some((tx, Vec::new()));
2616 let action = handle_inbound(
2617 json!({
2618 "type": "control_request",
2619 "request_id": "req-2",
2620 "request": { "subtype": "future_subtype" }
2621 }),
2622 &mut pending,
2623 &events_tx,
2624 );
2625 assert!(matches!(action, InboundAction::None));
2626 let event = events_rx.try_recv().expect("broadcast");
2627 assert!(matches!(event, InboundEvent::Other(_)));
2628 }
2629
2630 #[tokio::test]
2631 async fn permission_handler_invokes_closure_async() {
2632 let handler = PermissionHandler::new(|req| async move {
2633 if req.tool_name == "Bash" {
2634 PermissionDecision::Deny {
2635 message: "no bash".into(),
2636 }
2637 } else {
2638 PermissionDecision::Allow {
2639 updated_input: None,
2640 }
2641 }
2642 });
2643 let req = PermissionRequest {
2644 request_id: "r1".into(),
2645 tool_name: "Bash".into(),
2646 input: Value::Null,
2647 raw: Value::Null,
2648 };
2649 match handler.invoke(req).await {
2650 PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
2651 other => panic!("expected Deny, got {other:?}"),
2652 }
2653 }
2654
2655 #[test]
2656 fn parse_control_response_extracts_success() {
2657 let msg = json!({
2658 "type": "control_response",
2659 "response": {
2660 "request_id": "interrupt-1",
2661 "subtype": "success",
2662 "response": {}
2663 }
2664 });
2665 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2666 assert_eq!(id, "interrupt-1");
2667 assert!(outcome.is_ok());
2668 }
2669
2670 #[test]
2671 fn parse_control_response_extracts_error_with_message() {
2672 let msg = json!({
2673 "type": "control_response",
2674 "response": {
2675 "request_id": "interrupt-2",
2676 "subtype": "error",
2677 "error": "no turn in flight"
2678 }
2679 });
2680 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2681 assert_eq!(id, "interrupt-2");
2682 match outcome {
2683 Err(Error::DuplexControlFailed { message }) => {
2684 assert_eq!(message, "no turn in flight");
2685 }
2686 other => panic!("expected DuplexControlFailed, got {other:?}"),
2687 }
2688 }
2689
2690 #[test]
2691 fn parse_control_response_returns_none_on_missing_request_id() {
2692 let msg = json!({
2693 "type": "control_response",
2694 "response": { "subtype": "success" }
2695 });
2696 assert!(parse_control_response(&msg).is_none());
2697 }
2698
2699 #[test]
2700 fn parse_control_response_returns_none_on_unknown_subtype() {
2701 let msg = json!({
2702 "type": "control_response",
2703 "response": { "request_id": "x", "subtype": "future_subtype" }
2704 });
2705 assert!(parse_control_response(&msg).is_none());
2706 }
2707
2708 #[test]
2709 fn handle_inbound_returns_control_response_action() {
2710 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2711 let (events_tx, _events_rx) = broadcast::channel(16);
2712 let mut pending = Some((tx, Vec::new()));
2713 let action = handle_inbound(
2714 json!({
2715 "type": "control_response",
2716 "response": {
2717 "request_id": "interrupt-1",
2718 "subtype": "success",
2719 "response": {}
2720 }
2721 }),
2722 &mut pending,
2723 &events_tx,
2724 );
2725 match action {
2726 InboundAction::ControlResponse {
2727 request_id,
2728 outcome,
2729 } => {
2730 assert_eq!(request_id, "interrupt-1");
2731 assert!(outcome.is_ok());
2732 }
2733 InboundAction::None | InboundAction::Permission(_) => {
2734 panic!("expected ControlResponse action");
2735 }
2736 }
2737 }
2738
2739 #[test]
2740 fn handle_inbound_treats_malformed_control_response_as_other() {
2741 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2742 let (events_tx, mut events_rx) = broadcast::channel(16);
2743 let mut pending = Some((tx, Vec::new()));
2744 let action = handle_inbound(
2745 json!({
2746 "type": "control_response",
2747 "response": { "subtype": "success" }
2748 }),
2749 &mut pending,
2750 &events_tx,
2751 );
2752 assert!(matches!(action, InboundAction::None));
2753 let event = events_rx.try_recv().expect("broadcast");
2754 assert!(matches!(event, InboundEvent::Other(_)));
2755 }
2756
2757 #[tokio::test]
2758 async fn permission_handler_clones_arc() {
2759 let handler = PermissionHandler::new(|_req| async move {
2760 PermissionDecision::Allow {
2761 updated_input: None,
2762 }
2763 });
2764 let cloned = handler.clone();
2765 let req = PermissionRequest {
2766 request_id: "r1".into(),
2767 tool_name: "Read".into(),
2768 input: Value::Null,
2769 raw: Value::Null,
2770 };
2771 let _ = handler.invoke(req.clone()).await;
2773 let _ = cloned.invoke(req).await;
2774 }
2775
2776 fn fake_session(
2783 initial: SessionExitStatus,
2784 ) -> (
2785 DuplexSession,
2786 watch::Sender<SessionExitStatus>,
2787 oneshot::Sender<()>,
2788 ) {
2789 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
2790 let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
2791 let (exit_tx, exit_rx) = watch::channel(initial);
2792 let (stop_tx, stop_rx) = oneshot::channel::<()>();
2793
2794 let join = tokio::spawn(async move {
2795 let _outbound_rx = outbound_rx;
2796 let _ = stop_rx.await;
2797 Ok::<(), Error>(())
2798 });
2799
2800 let session = DuplexSession {
2801 outbound_tx,
2802 events_tx,
2803 exit_rx,
2804 join,
2805 };
2806 (session, exit_tx, stop_tx)
2807 }
2808
2809 #[tokio::test]
2810 async fn is_alive_true_while_running() {
2811 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2812 assert!(session.is_alive());
2813 }
2814
2815 #[tokio::test]
2816 async fn is_alive_false_after_completed() {
2817 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2818 exit_tx.send(SessionExitStatus::Completed).unwrap();
2819 assert!(!session.is_alive());
2820 }
2821
2822 #[tokio::test]
2823 async fn is_alive_false_after_failed() {
2824 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2825 exit_tx
2826 .send(SessionExitStatus::Failed("boom".into()))
2827 .unwrap();
2828 assert!(!session.is_alive());
2829 }
2830
2831 #[tokio::test]
2832 async fn exit_status_reports_running_initially() {
2833 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2834 assert!(matches!(session.exit_status(), SessionExitStatus::Running));
2835 }
2836
2837 #[tokio::test]
2838 async fn exit_status_reflects_completed() {
2839 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2840 exit_tx.send(SessionExitStatus::Completed).unwrap();
2841 assert!(matches!(
2842 session.exit_status(),
2843 SessionExitStatus::Completed
2844 ));
2845 }
2846
2847 #[tokio::test]
2848 async fn exit_status_reflects_failed_with_message() {
2849 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2850 exit_tx
2851 .send(SessionExitStatus::Failed("oh no".into()))
2852 .unwrap();
2853 match session.exit_status() {
2854 SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
2855 other => panic!("expected Failed, got {other:?}"),
2856 }
2857 }
2858
2859 #[tokio::test]
2860 async fn wait_for_exit_returns_immediately_when_already_terminal() {
2861 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2862 exit_tx.send(SessionExitStatus::Completed).unwrap();
2863 let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
2864 .await
2865 .expect("wait_for_exit should not block when already terminal");
2866 assert!(matches!(status, SessionExitStatus::Completed));
2867 }
2868
2869 #[tokio::test]
2870 async fn wait_for_exit_blocks_until_state_transitions() {
2871 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2872
2873 let waiter = async { session.wait_for_exit().await };
2874 let driver = async {
2875 tokio::time::sleep(Duration::from_millis(20)).await;
2876 exit_tx.send(SessionExitStatus::Completed).unwrap();
2877 };
2878 let (status, ()) = tokio::join!(waiter, driver);
2879 assert!(matches!(status, SessionExitStatus::Completed));
2880 }
2881
2882 #[tokio::test]
2883 async fn wait_for_exit_supports_multiple_observers() {
2884 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2885
2886 let waiter1 = async { session.wait_for_exit().await };
2887 let waiter2 = async { session.wait_for_exit().await };
2888 let driver = async {
2889 tokio::time::sleep(Duration::from_millis(20)).await;
2890 exit_tx
2891 .send(SessionExitStatus::Failed("crash".into()))
2892 .unwrap();
2893 };
2894 let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
2895 match s1 {
2896 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
2897 other => panic!("waiter1 expected Failed, got {other:?}"),
2898 }
2899 match s2 {
2900 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
2901 other => panic!("waiter2 expected Failed, got {other:?}"),
2902 }
2903 }
2904
2905 #[tokio::test]
2906 async fn wait_for_exit_returns_last_value_when_sender_dropped() {
2907 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2911 let waiter = async { session.wait_for_exit().await };
2912 let driver = async {
2913 tokio::time::sleep(Duration::from_millis(20)).await;
2914 drop(exit_tx);
2915 };
2916 let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
2917 tokio::join!(waiter, driver)
2918 })
2919 .await
2920 .expect("wait_for_exit must not hang when sender is dropped");
2921 assert!(matches!(status, SessionExitStatus::Running));
2922 }
2923}