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