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, 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)]
329pub struct DuplexOptions {
330 shared: SharedSpawnArgs,
333 additional_args: Vec<String>,
334 subscriber_capacity: Option<usize>,
335 on_permission: Option<PermissionHandler>,
336}
337
338impl DuplexOptions {
339 #[must_use]
341 pub fn model(mut self, model: impl Into<String>) -> Self {
342 self.shared.model = Some(model.into());
343 self
344 }
345
346 #[must_use]
348 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
349 self.shared.system_prompt = Some(prompt.into());
350 self
351 }
352
353 #[must_use]
355 pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
356 self.shared.append_system_prompt = Some(prompt.into());
357 self
358 }
359
360 #[must_use]
377 pub fn resume(mut self, session_id: impl Into<String>) -> Self {
378 self.shared.resume = Some(session_id.into());
379 self
380 }
381
382 #[must_use]
389 pub fn continue_session(mut self) -> Self {
390 self.shared.continue_session = true;
391 self
392 }
393
394 #[must_use]
405 pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
406 self.shared.worktree = true;
407 if let Some(n) = name {
408 self.shared.worktree_name = Some(n.into());
409 }
410 self
411 }
412
413 #[must_use]
427 pub fn agent(mut self, name: impl Into<String>) -> Self {
428 self.shared.agent = Some(name.into());
429 self
430 }
431
432 #[must_use]
444 pub fn agents_json(mut self, json: impl Into<String>) -> Self {
445 self.shared.agents_json = Some(json.into());
446 self
447 }
448
449 #[must_use]
465 pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
466 self.shared.permission_mode = Some(mode);
467 self
468 }
469
470 #[must_use]
478 pub fn dangerously_skip_permissions(mut self) -> Self {
479 self.shared.dangerously_skip_permissions = true;
480 self
481 }
482
483 #[must_use]
493 pub fn session_id(mut self, id: impl Into<String>) -> Self {
494 self.shared.session_id = Some(id.into());
495 self
496 }
497
498 #[must_use]
506 pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
507 self.shared.json_schema = Some(schema.into());
508 self
509 }
510
511 #[must_use]
519 pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
520 where
521 I: IntoIterator<Item = T>,
522 T: Into<ToolPattern>,
523 {
524 self.shared
525 .allowed_tools
526 .extend(tools.into_iter().map(Into::into));
527 self
528 }
529
530 #[must_use]
532 pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
533 self.shared.allowed_tools.push(tool.into());
534 self
535 }
536
537 #[must_use]
539 pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
540 where
541 I: IntoIterator<Item = T>,
542 T: Into<ToolPattern>,
543 {
544 self.shared
545 .disallowed_tools
546 .extend(tools.into_iter().map(Into::into));
547 self
548 }
549
550 #[must_use]
552 pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
553 self.shared.disallowed_tools.push(tool.into());
554 self
555 }
556
557 #[must_use]
563 pub fn max_turns(mut self, turns: u32) -> Self {
564 self.shared.max_turns = Some(turns);
565 self
566 }
567
568 #[must_use]
578 pub fn max_budget_usd(mut self, budget: f64) -> Self {
579 self.shared.max_budget_usd = Some(budget);
580 self
581 }
582
583 #[must_use]
586 pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
587 self.shared.fallback_model = Some(model.into());
588 self
589 }
590
591 #[must_use]
593 pub fn effort(mut self, effort: Effort) -> Self {
594 self.shared.effort = Some(effort);
595 self
596 }
597
598 #[must_use]
601 pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
602 self.shared.add_dir.push(dir.into());
603 self
604 }
605
606 #[must_use]
611 pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
612 self.shared.mcp_config.push(path.into());
613 self
614 }
615
616 #[must_use]
620 pub fn strict_mcp_config(mut self) -> Self {
621 self.shared.strict_mcp_config = true;
622 self
623 }
624
625 #[must_use]
628 pub fn no_session_persistence(mut self) -> Self {
629 self.shared.no_session_persistence = true;
630 self
631 }
632
633 #[must_use]
638 pub fn arg(mut self, arg: impl Into<String>) -> Self {
639 self.additional_args.push(arg.into());
640 self
641 }
642
643 #[must_use]
651 pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
652 self.subscriber_capacity = Some(capacity);
653 self
654 }
655
656 #[must_use]
674 pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
675 self.on_permission = Some(handler);
676 self
677 }
678
679 fn into_args(self) -> Vec<String> {
680 let mut args = vec![
681 "--print".to_string(),
682 "--verbose".to_string(),
683 "--output-format".to_string(),
684 "stream-json".to_string(),
685 "--input-format".to_string(),
686 "stream-json".to_string(),
687 ];
688
689 self.shared.append_to(&mut args);
690
691 if self.on_permission.is_some() {
692 args.push("--permission-prompt-tool".to_string());
693 args.push("stdio".to_string());
694 }
695 args.extend(self.additional_args);
696
697 args
698 }
699}
700
701#[derive(Debug, Clone)]
708pub struct TurnResult {
709 pub result: Value,
711 pub events: Vec<Value>,
713}
714
715impl TurnResult {
716 #[must_use]
718 pub fn result_text(&self) -> Option<&str> {
719 self.result.get("result").and_then(Value::as_str)
720 }
721
722 #[must_use]
724 pub fn session_id(&self) -> Option<&str> {
725 self.result.get("session_id").and_then(Value::as_str)
726 }
727
728 #[must_use]
735 pub fn total_cost_usd(&self) -> Option<f64> {
736 self.result
737 .get("total_cost_usd")
738 .or_else(|| self.result.get("cost_usd"))
739 .and_then(Value::as_f64)
740 }
741
742 #[must_use]
744 pub fn duration_ms(&self) -> Option<u64> {
745 self.result.get("duration_ms").and_then(Value::as_u64)
746 }
747}
748
749#[derive(Debug, Clone)]
763pub enum InboundEvent {
764 SystemInit {
767 session_id: String,
770 },
771 Assistant(Value),
774 StreamEvent(Value),
777 User(Value),
780 Other(Value),
783}
784
785fn classify(msg: &Value) -> InboundEvent {
786 match msg.get("type").and_then(Value::as_str) {
787 Some("system") => {
788 if msg.get("subtype").and_then(Value::as_str) == Some("init")
789 && let Some(id) = msg.get("session_id").and_then(Value::as_str)
790 {
791 return InboundEvent::SystemInit {
792 session_id: id.to_string(),
793 };
794 }
795 InboundEvent::Other(msg.clone())
796 }
797 Some("assistant") => InboundEvent::Assistant(msg.clone()),
798 Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
799 Some("user") => InboundEvent::User(msg.clone()),
800 _ => InboundEvent::Other(msg.clone()),
801 }
802}
803
804#[derive(Debug, Clone)]
819pub enum SessionExitStatus {
820 Running,
822 Completed,
825 Failed(String),
828}
829
830#[derive(Debug)]
839pub struct DuplexSession {
840 outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
841 events_tx: broadcast::Sender<InboundEvent>,
842 exit_rx: watch::Receiver<SessionExitStatus>,
843 join: JoinHandle<Result<()>>,
844}
845
846#[derive(Debug)]
847enum OutboundMsg {
848 Send {
849 prompt: String,
850 reply: oneshot::Sender<Result<TurnResult>>,
851 },
852 PermissionResponse {
853 request_id: String,
854 decision: PermissionDecision,
855 },
856 Interrupt {
857 reply: oneshot::Sender<Result<()>>,
858 },
859}
860
861impl DuplexSession {
862 pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
870 let capacity = opts
871 .subscriber_capacity
872 .unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
873 let permission_handler = opts.on_permission.clone();
874
875 let mut command_args = Vec::new();
876 command_args.extend(claude.global_args.clone());
877 command_args.extend(opts.into_args());
878
879 debug!(
880 binary = %claude.binary.display(),
881 args = ?command_args,
882 "spawning duplex claude session"
883 );
884
885 let mut cmd = Command::new(&claude.binary);
886 cmd.args(&command_args)
887 .env_remove("CLAUDECODE")
888 .env_remove("CLAUDE_CODE_ENTRYPOINT")
889 .envs(&claude.env)
890 .stdin(Stdio::piped())
891 .stdout(Stdio::piped())
892 .stderr(Stdio::piped())
893 .kill_on_drop(true);
894
895 if let Some(ref dir) = claude.working_dir {
896 cmd.current_dir(dir);
897 }
898
899 let mut child = cmd.spawn().map_err(|e| Error::Io {
900 message: format!("failed to spawn claude: {e}"),
901 source: e,
902 working_dir: claude.working_dir.clone(),
903 })?;
904
905 let stdin = child.stdin.take().expect("stdin was piped");
906 let stdout = child.stdout.take().expect("stdout was piped");
907
908 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
909 let (events_tx, _initial_rx) = broadcast::channel(capacity);
910 let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
911
912 let join = tokio::spawn(run_session(
913 child,
914 stdin,
915 stdout,
916 outbound_rx,
917 events_tx.clone(),
918 permission_handler,
919 exit_tx,
920 ));
921
922 Ok(Self {
923 outbound_tx,
924 events_tx,
925 exit_rx,
926 join,
927 })
928 }
929
930 pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
941 let (reply_tx, reply_rx) = oneshot::channel();
942 self.outbound_tx
943 .send(OutboundMsg::Send {
944 prompt: prompt.into(),
945 reply: reply_tx,
946 })
947 .map_err(|_| Error::DuplexClosed)?;
948 reply_rx.await.map_err(|_| Error::DuplexClosed)?
949 }
950
951 #[must_use]
986 pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
987 self.events_tx.subscribe()
988 }
989
990 #[must_use]
1001 pub fn is_alive(&self) -> bool {
1002 matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1003 }
1004
1005 #[must_use]
1014 pub fn exit_status(&self) -> SessionExitStatus {
1015 self.exit_rx.borrow().clone()
1016 }
1017
1018 pub async fn wait_for_exit(&self) -> SessionExitStatus {
1030 let mut rx = self.exit_rx.clone();
1031 loop {
1032 {
1033 let value = rx.borrow_and_update();
1034 if !matches!(*value, SessionExitStatus::Running) {
1035 return value.clone();
1036 }
1037 }
1038 if rx.changed().await.is_err() {
1039 return rx.borrow().clone();
1040 }
1041 }
1042 }
1043
1044 pub fn respond_to_permission(
1089 &self,
1090 request_id: impl Into<String>,
1091 decision: PermissionDecision,
1092 ) -> Result<()> {
1093 if matches!(decision, PermissionDecision::Defer) {
1094 warn!("respond_to_permission called with Defer; ignoring");
1095 return Ok(());
1096 }
1097 self.outbound_tx
1098 .send(OutboundMsg::PermissionResponse {
1099 request_id: request_id.into(),
1100 decision,
1101 })
1102 .map_err(|_| Error::DuplexClosed)?;
1103 Ok(())
1104 }
1105
1106 pub async fn interrupt(&self) -> Result<()> {
1147 let (reply_tx, reply_rx) = oneshot::channel();
1148 self.outbound_tx
1149 .send(OutboundMsg::Interrupt { reply: reply_tx })
1150 .map_err(|_| Error::DuplexClosed)?;
1151 reply_rx.await.map_err(|_| Error::DuplexClosed)?
1152 }
1153
1154 pub async fn close(self) -> Result<()> {
1160 drop(self.outbound_tx);
1161 drop(self.events_tx);
1162 match self.join.await {
1163 Ok(result) => result,
1164 Err(e) if e.is_cancelled() => Ok(()),
1165 Err(e) => Err(Error::Io {
1166 message: format!("duplex session task panicked: {e}"),
1167 source: std::io::Error::other(e.to_string()),
1168 working_dir: None,
1169 }),
1170 }
1171 }
1172}
1173
1174const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
1178
1179async fn run_session(
1180 mut child: Child,
1181 mut stdin: ChildStdin,
1182 stdout: ChildStdout,
1183 mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
1184 events_tx: broadcast::Sender<InboundEvent>,
1185 permission_handler: Option<PermissionHandler>,
1186 exit_tx: watch::Sender<SessionExitStatus>,
1187) -> Result<()> {
1188 let mut lines = BufReader::new(stdout).lines();
1189 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1190 let mut pending_control: HashMap<String, oneshot::Sender<Result<()>>> = HashMap::new();
1191 let mut next_control_id: u64 = 0;
1192 let mut stream_err: Option<Error> = None;
1193
1194 loop {
1195 tokio::select! {
1196 biased;
1197
1198 line = lines.next_line() => match line {
1199 Ok(Some(l)) => {
1200 if l.trim().is_empty() {
1201 continue;
1202 }
1203 let parsed = match serde_json::from_str::<Value>(&l) {
1204 Ok(v) => v,
1205 Err(e) => {
1206 debug!(line = %l, error = %e, "failed to parse duplex event, skipping");
1207 continue;
1208 }
1209 };
1210 match handle_inbound(parsed, &mut pending, &events_tx) {
1211 InboundAction::None => {}
1212 InboundAction::Permission(req) => {
1213 let request_id = req.request_id.clone();
1214 let decision = match permission_handler.as_ref() {
1215 Some(h) => h.invoke(req).await,
1216 None => {
1217 warn!(
1218 request_id = %request_id,
1219 "received can_use_tool with no permission handler; auto-denying"
1220 );
1221 PermissionDecision::Deny {
1222 message:
1223 "no permission handler configured on duplex session"
1224 .into(),
1225 }
1226 }
1227 };
1228 if matches!(decision, PermissionDecision::Defer) {
1229 debug!(
1230 request_id = %request_id,
1231 "permission handler deferred; waiting for respond_to_permission"
1232 );
1233 } else if let Err(e) =
1234 write_permission_response(&mut stdin, &request_id, &decision).await
1235 {
1236 warn!(error = %e, "failed to write permission response");
1237 }
1238 }
1239 InboundAction::ControlResponse { request_id, outcome } => {
1240 if let Some(reply) = pending_control.remove(&request_id) {
1241 let _ = reply.send(outcome);
1242 } else {
1243 debug!(
1244 request_id = %request_id,
1245 "received control_response with no pending request"
1246 );
1247 }
1248 }
1249 }
1250 }
1251 Ok(None) => break,
1252 Err(e) => {
1253 stream_err = Some(Error::Io {
1254 message: "failed to read duplex stdout".to_string(),
1255 source: e,
1256 working_dir: None,
1257 });
1258 break;
1259 }
1260 },
1261
1262 msg = outbound_rx.recv() => match msg {
1263 Some(OutboundMsg::Send { prompt, reply }) => {
1264 if pending.is_some() {
1265 let _ = reply.send(Err(Error::DuplexTurnInFlight));
1266 continue;
1267 }
1268 if let Err(e) = write_user(&mut stdin, &prompt).await {
1269 let _ = reply.send(Err(e));
1270 continue;
1271 }
1272 pending = Some((reply, Vec::new()));
1273 }
1274 Some(OutboundMsg::PermissionResponse { request_id, decision }) => {
1275 if let Err(e) =
1276 write_permission_response(&mut stdin, &request_id, &decision).await
1277 {
1278 warn!(error = %e, "failed to write deferred permission response");
1279 }
1280 }
1281 Some(OutboundMsg::Interrupt { reply }) => {
1282 next_control_id += 1;
1283 let request_id = format!("interrupt-{next_control_id}");
1284 if let Err(e) =
1285 write_control_request(&mut stdin, &request_id, "interrupt").await
1286 {
1287 let _ = reply.send(Err(e));
1288 continue;
1289 }
1290 pending_control.insert(request_id, reply);
1291 }
1292 None => break,
1293 },
1294 }
1295 }
1296
1297 drop(stdin);
1298 match tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()).await {
1299 Ok(Ok(_status)) => {}
1300 Ok(Err(e)) => {
1301 warn!(error = %e, "failed to wait for duplex child");
1302 }
1303 Err(_) => {
1304 warn!("duplex child did not exit within shutdown budget; killing");
1305 let _ = child.kill().await;
1306 }
1307 }
1308
1309 if let Some((reply, _)) = pending.take() {
1310 let _ = reply.send(Err(Error::DuplexClosed));
1311 }
1312 for (_, reply) in pending_control.drain() {
1313 let _ = reply.send(Err(Error::DuplexClosed));
1314 }
1315
1316 let result = match stream_err {
1317 Some(e) => Err(e),
1318 None => Ok(()),
1319 };
1320 let final_state = match &result {
1321 Ok(()) => SessionExitStatus::Completed,
1322 Err(e) => SessionExitStatus::Failed(e.to_string()),
1323 };
1324 let _ = exit_tx.send(final_state);
1325 result
1326}
1327
1328enum InboundAction {
1332 None,
1334 Permission(PermissionRequest),
1338 ControlResponse {
1343 request_id: String,
1344 outcome: Result<()>,
1345 },
1346}
1347
1348fn handle_inbound(
1349 msg: Value,
1350 pending: &mut Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>,
1351 events_tx: &broadcast::Sender<InboundEvent>,
1352) -> InboundAction {
1353 match msg.get("type").and_then(Value::as_str) {
1354 Some("result") => {
1355 if let Some((reply, events)) = pending.take() {
1356 let _ = reply.send(Ok(TurnResult {
1357 result: msg,
1358 events,
1359 }));
1360 } else {
1361 debug!("dropping orphan result event with no pending turn");
1362 }
1363 InboundAction::None
1364 }
1365 Some("control_request") => {
1366 if msg
1369 .get("request")
1370 .and_then(|r| r.get("subtype"))
1371 .and_then(Value::as_str)
1372 == Some("can_use_tool")
1373 && let Some(req) = parse_permission_request(&msg)
1374 {
1375 if let Some((_, events)) = pending.as_mut() {
1376 events.push(msg);
1377 }
1378 return InboundAction::Permission(req);
1379 }
1380 debug!(
1381 ?msg,
1382 "received unhandled control_request; treating as Other"
1383 );
1384 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1385 if let Some((_, events)) = pending.as_mut() {
1386 events.push(msg);
1387 }
1388 InboundAction::None
1389 }
1390 Some("control_response") => {
1391 if let Some((request_id, outcome)) = parse_control_response(&msg) {
1392 return InboundAction::ControlResponse {
1393 request_id,
1394 outcome,
1395 };
1396 }
1397 debug!(
1398 ?msg,
1399 "received malformed control_response; treating as Other"
1400 );
1401 let _ = events_tx.send(InboundEvent::Other(msg.clone()));
1402 if let Some((_, events)) = pending.as_mut() {
1403 events.push(msg);
1404 }
1405 InboundAction::None
1406 }
1407 _ => {
1408 let _ = events_tx.send(classify(&msg));
1411
1412 if let Some((_, events)) = pending.as_mut() {
1413 events.push(msg);
1414 } else {
1415 debug!("dropping inbound event with no pending turn");
1416 }
1417 InboundAction::None
1418 }
1419 }
1420}
1421
1422fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
1423 let request_id = msg.get("request_id").and_then(Value::as_str)?;
1424 let request = msg.get("request")?;
1425 let tool_name = request.get("tool_name").and_then(Value::as_str)?;
1426 let input = request.get("input").cloned().unwrap_or(Value::Null);
1427 Some(PermissionRequest {
1428 request_id: request_id.to_string(),
1429 tool_name: tool_name.to_string(),
1430 input,
1431 raw: request.clone(),
1432 })
1433}
1434
1435fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
1441 let response = msg.get("response")?;
1442 let request_id = response.get("request_id").and_then(Value::as_str)?;
1443 let outcome = match response.get("subtype").and_then(Value::as_str) {
1444 Some("success") => Ok(()),
1445 Some("error") => {
1446 let message = response
1447 .get("error")
1448 .and_then(Value::as_str)
1449 .unwrap_or("unknown control_response error")
1450 .to_string();
1451 Err(Error::DuplexControlFailed { message })
1452 }
1453 _ => return None,
1454 };
1455 Some((request_id.to_string(), outcome))
1456}
1457
1458async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
1459 let user_msg = serde_json::json!({
1460 "type": "user",
1461 "message": {
1462 "role": "user",
1463 "content": prompt,
1464 },
1465 "parent_tool_use_id": null,
1466 });
1467 write_line(stdin, &user_msg, "user message").await
1468}
1469
1470async fn write_control_request(
1471 stdin: &mut ChildStdin,
1472 request_id: &str,
1473 subtype: &str,
1474) -> Result<()> {
1475 let envelope = serde_json::json!({
1476 "type": "control_request",
1477 "request_id": request_id,
1478 "request": { "subtype": subtype },
1479 });
1480 write_line(stdin, &envelope, "control_request").await
1481}
1482
1483async fn write_permission_response(
1484 stdin: &mut ChildStdin,
1485 request_id: &str,
1486 decision: &PermissionDecision,
1487) -> Result<()> {
1488 let inner = match decision {
1489 PermissionDecision::Allow { updated_input } => {
1490 let mut obj = serde_json::Map::new();
1491 obj.insert("behavior".to_string(), Value::String("allow".to_string()));
1492 if let Some(input) = updated_input {
1493 obj.insert("updatedInput".to_string(), input.clone());
1494 }
1495 Value::Object(obj)
1496 }
1497 PermissionDecision::Deny { message } => serde_json::json!({
1498 "behavior": "deny",
1499 "message": message,
1500 }),
1501 PermissionDecision::Defer => {
1502 return Ok(());
1504 }
1505 };
1506 let envelope = serde_json::json!({
1507 "type": "control_response",
1508 "response": {
1509 "request_id": request_id,
1510 "subtype": "success",
1511 "response": inner,
1512 },
1513 });
1514 write_line(stdin, &envelope, "control_response").await
1515}
1516
1517async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
1518 let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
1519 message: format!("failed to serialize duplex {what}"),
1520 source: e,
1521 })?;
1522 line.push('\n');
1523 stdin
1524 .write_all(line.as_bytes())
1525 .await
1526 .map_err(|e| Error::Io {
1527 message: format!("failed to write {what} to duplex stdin"),
1528 source: e,
1529 working_dir: None,
1530 })?;
1531 stdin.flush().await.map_err(|e| Error::Io {
1532 message: "failed to flush duplex stdin".to_string(),
1533 source: e,
1534 working_dir: None,
1535 })?;
1536 Ok(())
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::*;
1542 use serde_json::json;
1543
1544 #[test]
1545 fn into_args_default_includes_required_flags() {
1546 let args = DuplexOptions::default().into_args();
1547 assert!(args.contains(&"--print".to_string()));
1548 assert!(args.contains(&"--verbose".to_string()));
1549 assert!(
1550 args.windows(2)
1551 .any(|w| w == ["--output-format", "stream-json"])
1552 );
1553 assert!(
1554 args.windows(2)
1555 .any(|w| w == ["--input-format", "stream-json"])
1556 );
1557 }
1558
1559 #[test]
1560 fn into_args_includes_model() {
1561 let args = DuplexOptions::default().model("haiku").into_args();
1562 assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
1563 }
1564
1565 #[test]
1566 fn into_args_includes_system_prompts() {
1567 let args = DuplexOptions::default()
1568 .system_prompt("be concise")
1569 .append_system_prompt("also polite")
1570 .into_args();
1571 assert!(
1572 args.windows(2)
1573 .any(|w| w == ["--system-prompt", "be concise"])
1574 );
1575 assert!(
1576 args.windows(2)
1577 .any(|w| w == ["--append-system-prompt", "also polite"])
1578 );
1579 }
1580
1581 #[test]
1582 fn into_args_appends_raw_args_last() {
1583 let args = DuplexOptions::default()
1584 .arg("--add-dir")
1585 .arg("/tmp/foo")
1586 .into_args();
1587 assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
1589 }
1590
1591 #[test]
1592 fn into_args_includes_resume_when_set() {
1593 let args = DuplexOptions::default().resume("abc-123").into_args();
1594 assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
1595 }
1596
1597 #[test]
1598 fn into_args_omits_resume_by_default() {
1599 let args = DuplexOptions::default().into_args();
1600 assert!(
1601 !args.iter().any(|a| a == "--resume"),
1602 "--resume should not appear without an explicit resume(...) call; got {args:?}"
1603 );
1604 }
1605
1606 #[test]
1607 fn into_args_includes_continue_when_set() {
1608 let args = DuplexOptions::default().continue_session().into_args();
1609 assert!(args.iter().any(|a| a == "--continue"));
1610 }
1611
1612 #[test]
1613 fn into_args_omits_continue_by_default() {
1614 let args = DuplexOptions::default().into_args();
1615 assert!(!args.iter().any(|a| a == "--continue"));
1616 }
1617
1618 #[test]
1619 fn into_args_includes_worktree_flag_without_name() {
1620 let args = DuplexOptions::default().worktree(None::<&str>).into_args();
1621 assert!(args.iter().any(|a| a == "--worktree"));
1622 let pos = args.iter().position(|a| a == "--worktree").unwrap();
1624 assert!(
1625 args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
1626 "--worktree without a name should not be followed by a positional; got {args:?}"
1627 );
1628 }
1629
1630 #[test]
1631 fn into_args_includes_worktree_flag_with_name() {
1632 let args = DuplexOptions::default()
1633 .worktree(Some("agent-xyz"))
1634 .into_args();
1635 let pos = args.iter().position(|a| a == "--worktree").unwrap();
1636 assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
1637 }
1638
1639 #[test]
1640 fn into_args_omits_worktree_by_default() {
1641 let args = DuplexOptions::default().into_args();
1642 assert!(
1643 !args.iter().any(|a| a == "--worktree"),
1644 "--worktree should not appear without an explicit worktree(...) call; got {args:?}"
1645 );
1646 }
1647
1648 #[test]
1649 fn worktree_lands_before_additional_args() {
1650 let args = DuplexOptions::default()
1652 .worktree(Some("foo"))
1653 .arg("--")
1654 .arg("trailing")
1655 .into_args();
1656 let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
1657 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1658 assert!(
1659 wt_pos < dash_dash_pos,
1660 "--worktree must precede `--` separator; got {args:?}"
1661 );
1662 }
1663
1664 #[test]
1665 fn into_args_includes_agent_when_set() {
1666 let args = DuplexOptions::default().agent("rust-qa").into_args();
1667 assert!(
1668 args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
1669 "missing --agent rust-qa in {args:?}"
1670 );
1671 }
1672
1673 #[test]
1674 fn into_args_omits_agent_by_default() {
1675 let args = DuplexOptions::default().into_args();
1676 assert!(
1677 !args.iter().any(|a| a == "--agent"),
1678 "--agent should not appear without an explicit agent(...) call; got {args:?}"
1679 );
1680 }
1681
1682 #[test]
1683 fn into_args_includes_agents_json_when_set() {
1684 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
1685 let args = DuplexOptions::default().agents_json(json).into_args();
1686 let pos = args.iter().position(|a| a == "--agents").unwrap();
1687 assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
1688 }
1689
1690 #[test]
1691 fn into_args_omits_agents_json_by_default() {
1692 let args = DuplexOptions::default().into_args();
1693 assert!(!args.iter().any(|a| a == "--agents"));
1694 }
1695
1696 #[test]
1697 fn agent_and_agents_json_compose() {
1698 let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
1699 let args = DuplexOptions::default()
1700 .agents_json(json)
1701 .agent("reviewer")
1702 .into_args();
1703 assert!(args.iter().any(|a| a == "--agents"));
1705 assert!(args.iter().any(|a| a == "--agent"));
1706 }
1707
1708 #[test]
1709 fn agent_lands_before_additional_args() {
1710 let args = DuplexOptions::default()
1711 .agent("rust-qa")
1712 .arg("--")
1713 .arg("trailing")
1714 .into_args();
1715 let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
1716 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1717 assert!(
1718 agent_pos < dash_dash_pos,
1719 "--agent must precede `--` separator; got {args:?}"
1720 );
1721 }
1722
1723 #[test]
1724 fn agents_json_lands_before_additional_args() {
1725 let args = DuplexOptions::default()
1726 .agents_json("{}")
1727 .arg("--")
1728 .arg("trailing")
1729 .into_args();
1730 let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
1731 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1732 assert!(
1733 agents_pos < dash_dash_pos,
1734 "--agents must precede `--` separator; got {args:?}"
1735 );
1736 }
1737
1738 #[test]
1741 fn into_args_includes_session_id() {
1742 let args = DuplexOptions::default().session_id("sid-9").into_args();
1743 assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
1744 }
1745
1746 #[test]
1747 fn into_args_includes_json_schema() {
1748 let schema = r#"{"type":"object"}"#;
1749 let args = DuplexOptions::default().json_schema(schema).into_args();
1750 assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
1751 }
1752
1753 #[test]
1754 fn into_args_joins_allowed_tools_comma_separated() {
1755 let args = DuplexOptions::default()
1756 .allowed_tools(["Read", "Bash(git log:*)"])
1757 .allowed_tool("Write")
1758 .into_args();
1759 assert!(
1760 args.windows(2)
1761 .any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
1762 "missing joined --allowed-tools in {args:?}"
1763 );
1764 }
1765
1766 #[test]
1767 fn into_args_joins_disallowed_tools_comma_separated() {
1768 let args = DuplexOptions::default()
1769 .disallowed_tools(["WebSearch"])
1770 .disallowed_tool("WebFetch")
1771 .into_args();
1772 assert!(
1773 args.windows(2)
1774 .any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
1775 "missing joined --disallowed-tools in {args:?}"
1776 );
1777 }
1778
1779 #[test]
1780 fn into_args_includes_caps() {
1781 let args = DuplexOptions::default()
1782 .max_turns(4)
1783 .max_budget_usd(0.25)
1784 .into_args();
1785 assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
1786 assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
1787 }
1788
1789 #[test]
1790 fn into_args_includes_fallback_model_and_effort() {
1791 let args = DuplexOptions::default()
1792 .fallback_model("haiku")
1793 .effort(Effort::Low)
1794 .into_args();
1795 assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
1796 assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
1797 }
1798
1799 #[test]
1800 fn into_args_repeats_add_dir_and_mcp_config() {
1801 let args = DuplexOptions::default()
1802 .add_dir("/a")
1803 .add_dir("/b")
1804 .mcp_config("x.json")
1805 .strict_mcp_config()
1806 .into_args();
1807 assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
1808 assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
1809 assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
1810 assert!(args.iter().any(|a| a == "--strict-mcp-config"));
1811 }
1812
1813 #[test]
1814 fn into_args_includes_no_session_persistence() {
1815 let args = DuplexOptions::default()
1816 .no_session_persistence()
1817 .into_args();
1818 assert!(args.iter().any(|a| a == "--no-session-persistence"));
1819 }
1820
1821 #[test]
1822 fn parity_flags_land_before_additional_args() {
1823 let args = DuplexOptions::default()
1825 .max_turns(2)
1826 .json_schema("{}")
1827 .arg("--")
1828 .arg("trailing")
1829 .into_args();
1830 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1831 for flag in ["--max-turns", "--json-schema"] {
1832 let pos = args.iter().position(|a| a == flag).unwrap();
1833 assert!(
1834 pos < dash_dash_pos,
1835 "{flag} must precede `--` separator; got {args:?}"
1836 );
1837 }
1838 }
1839
1840 #[test]
1841 fn into_args_omits_parity_flags_by_default() {
1842 let args = DuplexOptions::default().into_args();
1843 for flag in [
1844 "--session-id",
1845 "--json-schema",
1846 "--allowed-tools",
1847 "--disallowed-tools",
1848 "--max-turns",
1849 "--max-budget-usd",
1850 "--fallback-model",
1851 "--effort",
1852 "--add-dir",
1853 "--mcp-config",
1854 "--strict-mcp-config",
1855 "--no-session-persistence",
1856 ] {
1857 assert!(
1858 !args.iter().any(|a| a == flag),
1859 "{flag} should not appear by default; got {args:?}"
1860 );
1861 }
1862 }
1863
1864 #[test]
1865 fn resume_lands_before_additional_args() {
1866 let args = DuplexOptions::default()
1871 .resume("xyz")
1872 .arg("--")
1873 .arg("trailing")
1874 .into_args();
1875 let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
1876 let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
1877 assert!(
1878 resume_pos < dash_dash_pos,
1879 "--resume must precede `--` separator; got {args:?}"
1880 );
1881 }
1882
1883 #[test]
1884 fn turn_result_accessors_pull_from_result() {
1885 let r = TurnResult {
1886 result: json!({
1887 "type": "result",
1888 "result": "hello",
1889 "session_id": "sess-123",
1890 "total_cost_usd": 0.0042,
1891 "duration_ms": 1234_u64,
1892 }),
1893 events: vec![],
1894 };
1895 assert_eq!(r.result_text(), Some("hello"));
1896 assert_eq!(r.session_id(), Some("sess-123"));
1897 assert_eq!(r.total_cost_usd(), Some(0.0042));
1898 assert_eq!(r.duration_ms(), Some(1234));
1899 }
1900
1901 #[test]
1902 fn turn_result_total_cost_falls_back_to_legacy_field() {
1903 let r = TurnResult {
1904 result: json!({ "cost_usd": 0.5 }),
1905 events: vec![],
1906 };
1907 assert_eq!(r.total_cost_usd(), Some(0.5));
1908 }
1909
1910 #[test]
1911 fn turn_result_accessors_return_none_when_missing() {
1912 let r = TurnResult {
1913 result: json!({}),
1914 events: vec![],
1915 };
1916 assert_eq!(r.result_text(), None);
1917 assert_eq!(r.session_id(), None);
1918 assert_eq!(r.total_cost_usd(), None);
1919 assert_eq!(r.duration_ms(), None);
1920 }
1921
1922 #[test]
1923 fn handle_inbound_appends_non_result_to_pending_events() {
1924 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
1925 let (events_tx, _events_rx) = broadcast::channel(16);
1926 let mut pending = Some((tx, Vec::new()));
1927 handle_inbound(
1928 json!({ "type": "assistant", "message": {} }),
1929 &mut pending,
1930 &events_tx,
1931 );
1932 let (_, events) = pending.as_ref().unwrap();
1933 assert_eq!(events.len(), 1);
1934 assert_eq!(
1935 events[0].get("type").and_then(Value::as_str),
1936 Some("assistant")
1937 );
1938 }
1939
1940 #[test]
1941 fn handle_inbound_resolves_pending_on_result() {
1942 let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
1943 let (events_tx, _events_rx) = broadcast::channel(16);
1944 let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
1945 handle_inbound(
1946 json!({ "type": "result", "result": "ok" }),
1947 &mut pending,
1948 &events_tx,
1949 );
1950 assert!(pending.is_none());
1951 let received = rx.blocking_recv().unwrap().unwrap();
1952 assert_eq!(received.result_text(), Some("ok"));
1953 assert_eq!(received.events.len(), 1);
1954 }
1955
1956 #[test]
1957 fn handle_inbound_drops_orphans_without_pending_turn() {
1958 let (events_tx, _events_rx) = broadcast::channel(16);
1959 let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
1960 handle_inbound(json!({ "type": "assistant" }), &mut pending, &events_tx);
1961 handle_inbound(
1962 json!({ "type": "result", "result": "ok" }),
1963 &mut pending,
1964 &events_tx,
1965 );
1966 assert!(pending.is_none());
1967 }
1968
1969 #[test]
1970 fn handle_inbound_broadcasts_classified_event() {
1971 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
1972 let (events_tx, mut events_rx) = broadcast::channel(16);
1973 let mut pending = Some((tx, Vec::new()));
1974 handle_inbound(
1975 json!({ "type": "assistant", "message": { "role": "assistant" } }),
1976 &mut pending,
1977 &events_tx,
1978 );
1979 let event = events_rx.try_recv().expect("classified event broadcast");
1980 assert!(matches!(event, InboundEvent::Assistant(_)));
1981 }
1982
1983 #[test]
1984 fn handle_inbound_does_not_broadcast_result() {
1985 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
1986 let (events_tx, mut events_rx) = broadcast::channel(16);
1987 let mut pending = Some((tx, Vec::new()));
1988 handle_inbound(
1989 json!({ "type": "result", "result": "ok" }),
1990 &mut pending,
1991 &events_tx,
1992 );
1993 assert!(events_rx.try_recv().is_err());
1995 }
1996
1997 #[test]
1998 fn classify_system_init_pulls_session_id() {
1999 let v = json!({
2000 "type": "system",
2001 "subtype": "init",
2002 "session_id": "sess-abc",
2003 });
2004 match classify(&v) {
2005 InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
2006 other => panic!("expected SystemInit, got {other:?}"),
2007 }
2008 }
2009
2010 #[test]
2011 fn classify_system_without_init_subtype_is_other() {
2012 let v = json!({ "type": "system", "subtype": "compaction" });
2013 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2014 }
2015
2016 #[test]
2017 fn classify_system_init_without_session_id_is_other() {
2018 let v = json!({ "type": "system", "subtype": "init" });
2019 assert!(matches!(classify(&v), InboundEvent::Other(_)));
2020 }
2021
2022 #[test]
2023 fn classify_assistant_stream_event_user() {
2024 assert!(matches!(
2025 classify(&json!({ "type": "assistant" })),
2026 InboundEvent::Assistant(_)
2027 ));
2028 assert!(matches!(
2029 classify(&json!({ "type": "stream_event" })),
2030 InboundEvent::StreamEvent(_)
2031 ));
2032 assert!(matches!(
2033 classify(&json!({ "type": "user" })),
2034 InboundEvent::User(_)
2035 ));
2036 }
2037
2038 #[test]
2039 fn classify_unknown_type_is_other() {
2040 assert!(matches!(
2041 classify(&json!({ "type": "control_request" })),
2042 InboundEvent::Other(_)
2043 ));
2044 assert!(matches!(
2045 classify(&json!({ "type": "future_thing" })),
2046 InboundEvent::Other(_)
2047 ));
2048 assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
2049 }
2050
2051 #[test]
2052 fn into_args_does_not_emit_subscriber_capacity_flag() {
2053 let args = DuplexOptions::default().subscriber_capacity(64).into_args();
2055 assert!(!args.iter().any(|a| a.contains("subscriber")));
2056 assert!(!args.iter().any(|a| a.contains("capacity")));
2057 }
2058
2059 #[test]
2060 fn into_args_includes_permission_prompt_tool_when_handler_set() {
2061 let handler = PermissionHandler::new(|_req| async move {
2062 PermissionDecision::Allow {
2063 updated_input: None,
2064 }
2065 });
2066 let args = DuplexOptions::default().on_permission(handler).into_args();
2067 assert!(
2068 args.windows(2)
2069 .any(|w| w == ["--permission-prompt-tool", "stdio"])
2070 );
2071 }
2072
2073 #[test]
2074 fn into_args_omits_permission_prompt_tool_without_handler() {
2075 let args = DuplexOptions::default().into_args();
2076 assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
2077 }
2078
2079 #[test]
2080 fn into_args_emits_permission_mode_flag() {
2081 let args = DuplexOptions::default()
2082 .permission_mode(PermissionMode::AcceptEdits)
2083 .into_args();
2084 assert!(
2085 args.windows(2)
2086 .any(|w| w == ["--permission-mode", "acceptEdits"]),
2087 "missing --permission-mode acceptEdits in {args:?}"
2088 );
2089 }
2090
2091 #[test]
2092 fn into_args_emits_plan_mode() {
2093 let args = DuplexOptions::default()
2094 .permission_mode(PermissionMode::Plan)
2095 .into_args();
2096 assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
2097 }
2098
2099 #[test]
2100 fn into_args_omits_permission_mode_by_default() {
2101 let args = DuplexOptions::default().into_args();
2102 assert!(!args.iter().any(|a| a == "--permission-mode"));
2103 }
2104
2105 #[test]
2106 fn into_args_emits_dangerously_skip_permissions_flag() {
2107 let args = DuplexOptions::default()
2108 .dangerously_skip_permissions()
2109 .into_args();
2110 assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
2111 }
2112
2113 #[test]
2114 fn into_args_omits_dangerously_skip_by_default() {
2115 let args = DuplexOptions::default().into_args();
2116 assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
2117 }
2118
2119 #[test]
2120 fn parse_permission_request_extracts_fields() {
2121 let msg = json!({
2122 "type": "control_request",
2123 "request_id": "req-1",
2124 "request": {
2125 "subtype": "can_use_tool",
2126 "tool_name": "Bash",
2127 "input": { "command": "ls" }
2128 }
2129 });
2130 let req = parse_permission_request(&msg).expect("permission request");
2131 assert_eq!(req.request_id, "req-1");
2132 assert_eq!(req.tool_name, "Bash");
2133 assert_eq!(req.input, json!({ "command": "ls" }));
2134 assert_eq!(
2135 req.raw.get("subtype").and_then(Value::as_str),
2136 Some("can_use_tool")
2137 );
2138 }
2139
2140 #[test]
2141 fn parse_permission_request_returns_none_when_missing_request_id() {
2142 let msg = json!({
2143 "type": "control_request",
2144 "request": {
2145 "subtype": "can_use_tool",
2146 "tool_name": "Bash",
2147 }
2148 });
2149 assert!(parse_permission_request(&msg).is_none());
2150 }
2151
2152 #[test]
2153 fn parse_permission_request_returns_none_when_missing_tool_name() {
2154 let msg = json!({
2155 "type": "control_request",
2156 "request_id": "req-1",
2157 "request": { "subtype": "can_use_tool" }
2158 });
2159 assert!(parse_permission_request(&msg).is_none());
2160 }
2161
2162 #[test]
2163 fn parse_permission_request_handles_missing_input() {
2164 let msg = json!({
2165 "type": "control_request",
2166 "request_id": "req-1",
2167 "request": {
2168 "subtype": "can_use_tool",
2169 "tool_name": "Bash",
2170 }
2171 });
2172 let req = parse_permission_request(&msg).expect("request");
2173 assert_eq!(req.input, Value::Null);
2174 }
2175
2176 #[test]
2177 fn handle_inbound_returns_permission_for_can_use_tool() {
2178 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2179 let (events_tx, _events_rx) = broadcast::channel(16);
2180 let mut pending = Some((tx, Vec::new()));
2181 let action = handle_inbound(
2182 json!({
2183 "type": "control_request",
2184 "request_id": "req-1",
2185 "request": {
2186 "subtype": "can_use_tool",
2187 "tool_name": "Bash",
2188 "input": { "command": "ls" }
2189 }
2190 }),
2191 &mut pending,
2192 &events_tx,
2193 );
2194 match action {
2195 InboundAction::Permission(req) => {
2196 assert_eq!(req.request_id, "req-1");
2197 assert_eq!(req.tool_name, "Bash");
2198 }
2199 InboundAction::None | InboundAction::ControlResponse { .. } => {
2200 panic!("expected Permission action");
2201 }
2202 }
2203 let (_, events) = pending.as_ref().unwrap();
2205 assert_eq!(events.len(), 1);
2206 }
2207
2208 #[test]
2209 fn handle_inbound_treats_unknown_control_request_as_other() {
2210 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2211 let (events_tx, mut events_rx) = broadcast::channel(16);
2212 let mut pending = Some((tx, Vec::new()));
2213 let action = handle_inbound(
2214 json!({
2215 "type": "control_request",
2216 "request_id": "req-2",
2217 "request": { "subtype": "future_subtype" }
2218 }),
2219 &mut pending,
2220 &events_tx,
2221 );
2222 assert!(matches!(action, InboundAction::None));
2223 let event = events_rx.try_recv().expect("broadcast");
2224 assert!(matches!(event, InboundEvent::Other(_)));
2225 }
2226
2227 #[tokio::test]
2228 async fn permission_handler_invokes_closure_async() {
2229 let handler = PermissionHandler::new(|req| async move {
2230 if req.tool_name == "Bash" {
2231 PermissionDecision::Deny {
2232 message: "no bash".into(),
2233 }
2234 } else {
2235 PermissionDecision::Allow {
2236 updated_input: None,
2237 }
2238 }
2239 });
2240 let req = PermissionRequest {
2241 request_id: "r1".into(),
2242 tool_name: "Bash".into(),
2243 input: Value::Null,
2244 raw: Value::Null,
2245 };
2246 match handler.invoke(req).await {
2247 PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
2248 other => panic!("expected Deny, got {other:?}"),
2249 }
2250 }
2251
2252 #[test]
2253 fn parse_control_response_extracts_success() {
2254 let msg = json!({
2255 "type": "control_response",
2256 "response": {
2257 "request_id": "interrupt-1",
2258 "subtype": "success",
2259 "response": {}
2260 }
2261 });
2262 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2263 assert_eq!(id, "interrupt-1");
2264 assert!(outcome.is_ok());
2265 }
2266
2267 #[test]
2268 fn parse_control_response_extracts_error_with_message() {
2269 let msg = json!({
2270 "type": "control_response",
2271 "response": {
2272 "request_id": "interrupt-2",
2273 "subtype": "error",
2274 "error": "no turn in flight"
2275 }
2276 });
2277 let (id, outcome) = parse_control_response(&msg).expect("parsed");
2278 assert_eq!(id, "interrupt-2");
2279 match outcome {
2280 Err(Error::DuplexControlFailed { message }) => {
2281 assert_eq!(message, "no turn in flight");
2282 }
2283 other => panic!("expected DuplexControlFailed, got {other:?}"),
2284 }
2285 }
2286
2287 #[test]
2288 fn parse_control_response_returns_none_on_missing_request_id() {
2289 let msg = json!({
2290 "type": "control_response",
2291 "response": { "subtype": "success" }
2292 });
2293 assert!(parse_control_response(&msg).is_none());
2294 }
2295
2296 #[test]
2297 fn parse_control_response_returns_none_on_unknown_subtype() {
2298 let msg = json!({
2299 "type": "control_response",
2300 "response": { "request_id": "x", "subtype": "future_subtype" }
2301 });
2302 assert!(parse_control_response(&msg).is_none());
2303 }
2304
2305 #[test]
2306 fn handle_inbound_returns_control_response_action() {
2307 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2308 let (events_tx, _events_rx) = broadcast::channel(16);
2309 let mut pending = Some((tx, Vec::new()));
2310 let action = handle_inbound(
2311 json!({
2312 "type": "control_response",
2313 "response": {
2314 "request_id": "interrupt-1",
2315 "subtype": "success",
2316 "response": {}
2317 }
2318 }),
2319 &mut pending,
2320 &events_tx,
2321 );
2322 match action {
2323 InboundAction::ControlResponse {
2324 request_id,
2325 outcome,
2326 } => {
2327 assert_eq!(request_id, "interrupt-1");
2328 assert!(outcome.is_ok());
2329 }
2330 InboundAction::None | InboundAction::Permission(_) => {
2331 panic!("expected ControlResponse action");
2332 }
2333 }
2334 }
2335
2336 #[test]
2337 fn handle_inbound_treats_malformed_control_response_as_other() {
2338 let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
2339 let (events_tx, mut events_rx) = broadcast::channel(16);
2340 let mut pending = Some((tx, Vec::new()));
2341 let action = handle_inbound(
2342 json!({
2343 "type": "control_response",
2344 "response": { "subtype": "success" }
2345 }),
2346 &mut pending,
2347 &events_tx,
2348 );
2349 assert!(matches!(action, InboundAction::None));
2350 let event = events_rx.try_recv().expect("broadcast");
2351 assert!(matches!(event, InboundEvent::Other(_)));
2352 }
2353
2354 #[tokio::test]
2355 async fn permission_handler_clones_arc() {
2356 let handler = PermissionHandler::new(|_req| async move {
2357 PermissionDecision::Allow {
2358 updated_input: None,
2359 }
2360 });
2361 let cloned = handler.clone();
2362 let req = PermissionRequest {
2363 request_id: "r1".into(),
2364 tool_name: "Read".into(),
2365 input: Value::Null,
2366 raw: Value::Null,
2367 };
2368 let _ = handler.invoke(req.clone()).await;
2370 let _ = cloned.invoke(req).await;
2371 }
2372
2373 fn fake_session(
2380 initial: SessionExitStatus,
2381 ) -> (
2382 DuplexSession,
2383 watch::Sender<SessionExitStatus>,
2384 oneshot::Sender<()>,
2385 ) {
2386 let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
2387 let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
2388 let (exit_tx, exit_rx) = watch::channel(initial);
2389 let (stop_tx, stop_rx) = oneshot::channel::<()>();
2390
2391 let join = tokio::spawn(async move {
2392 let _outbound_rx = outbound_rx;
2393 let _ = stop_rx.await;
2394 Ok::<(), Error>(())
2395 });
2396
2397 let session = DuplexSession {
2398 outbound_tx,
2399 events_tx,
2400 exit_rx,
2401 join,
2402 };
2403 (session, exit_tx, stop_tx)
2404 }
2405
2406 #[tokio::test]
2407 async fn is_alive_true_while_running() {
2408 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2409 assert!(session.is_alive());
2410 }
2411
2412 #[tokio::test]
2413 async fn is_alive_false_after_completed() {
2414 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2415 exit_tx.send(SessionExitStatus::Completed).unwrap();
2416 assert!(!session.is_alive());
2417 }
2418
2419 #[tokio::test]
2420 async fn is_alive_false_after_failed() {
2421 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2422 exit_tx
2423 .send(SessionExitStatus::Failed("boom".into()))
2424 .unwrap();
2425 assert!(!session.is_alive());
2426 }
2427
2428 #[tokio::test]
2429 async fn exit_status_reports_running_initially() {
2430 let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2431 assert!(matches!(session.exit_status(), SessionExitStatus::Running));
2432 }
2433
2434 #[tokio::test]
2435 async fn exit_status_reflects_completed() {
2436 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2437 exit_tx.send(SessionExitStatus::Completed).unwrap();
2438 assert!(matches!(
2439 session.exit_status(),
2440 SessionExitStatus::Completed
2441 ));
2442 }
2443
2444 #[tokio::test]
2445 async fn exit_status_reflects_failed_with_message() {
2446 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2447 exit_tx
2448 .send(SessionExitStatus::Failed("oh no".into()))
2449 .unwrap();
2450 match session.exit_status() {
2451 SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
2452 other => panic!("expected Failed, got {other:?}"),
2453 }
2454 }
2455
2456 #[tokio::test]
2457 async fn wait_for_exit_returns_immediately_when_already_terminal() {
2458 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2459 exit_tx.send(SessionExitStatus::Completed).unwrap();
2460 let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
2461 .await
2462 .expect("wait_for_exit should not block when already terminal");
2463 assert!(matches!(status, SessionExitStatus::Completed));
2464 }
2465
2466 #[tokio::test]
2467 async fn wait_for_exit_blocks_until_state_transitions() {
2468 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2469
2470 let waiter = async { session.wait_for_exit().await };
2471 let driver = async {
2472 tokio::time::sleep(Duration::from_millis(20)).await;
2473 exit_tx.send(SessionExitStatus::Completed).unwrap();
2474 };
2475 let (status, ()) = tokio::join!(waiter, driver);
2476 assert!(matches!(status, SessionExitStatus::Completed));
2477 }
2478
2479 #[tokio::test]
2480 async fn wait_for_exit_supports_multiple_observers() {
2481 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2482
2483 let waiter1 = async { session.wait_for_exit().await };
2484 let waiter2 = async { session.wait_for_exit().await };
2485 let driver = async {
2486 tokio::time::sleep(Duration::from_millis(20)).await;
2487 exit_tx
2488 .send(SessionExitStatus::Failed("crash".into()))
2489 .unwrap();
2490 };
2491 let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
2492 match s1 {
2493 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
2494 other => panic!("waiter1 expected Failed, got {other:?}"),
2495 }
2496 match s2 {
2497 SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
2498 other => panic!("waiter2 expected Failed, got {other:?}"),
2499 }
2500 }
2501
2502 #[tokio::test]
2503 async fn wait_for_exit_returns_last_value_when_sender_dropped() {
2504 let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
2508 let waiter = async { session.wait_for_exit().await };
2509 let driver = async {
2510 tokio::time::sleep(Duration::from_millis(20)).await;
2511 drop(exit_tx);
2512 };
2513 let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
2514 tokio::join!(waiter, driver)
2515 })
2516 .await
2517 .expect("wait_for_exit must not hang when sender is dropped");
2518 assert!(matches!(status, SessionExitStatus::Running));
2519 }
2520}