Skip to main content

claude_wrapper/
duplex.rs

1//! Long-lived duplex stream-json sessions.
2//!
3//! [`DuplexSession`] holds a `claude` subprocess open in
4//! `--input-format stream-json --output-format stream-json` mode for
5//! the duration of a conversation. A single child is held open across
6//! many turns; user messages are written to its stdin, NDJSON events
7//! are read from its stdout and dispatched back to `send()` callers.
8//!
9//! # When to use
10//!
11//! [`DuplexSession`] is the recommended primitive for long-running
12//! hosts that drive multi-turn conversations: agent servers, IDE
13//! backends, daemons, chat UIs. Holding the child open across turns
14//! amortizes init cost and unlocks capabilities that are awkward or
15//! impossible from a transient subprocess: mid-turn permission
16//! decisions ([`PermissionHandler`]), clean
17//! [interrupts](DuplexSession::interrupt), and a typed
18//! [event subscriber stream](DuplexSession::subscribe) that fans out
19//! events to multiple consumers.
20//!
21//! For short-lived processes (CLIs, build scripts, batch jobs,
22//! lambdas) where each turn can stand on its own, prefer
23//! [`QueryCommand`] for one-off calls or [`Session`] for transient
24//! multi-turn with cumulative cost / history tracking.
25//!
26//! # Cost and budget bookkeeping
27//!
28//! [`DuplexSession`] itself keeps no cross-turn accounting: each
29//! [`TurnResult`] carries that turn's cost and nothing accumulates.
30//! For cumulative cost, turn history, and a
31//! [`BudgetTracker`]-enforced spend ceiling (send fails fast with
32//! [`Error::BudgetExceeded`] once the ceiling is hit), wrap the
33//! session in a [`Conversation`] --
34//! it is a thin bookkeeping layer over this module, not a different
35//! transport.
36//!
37//! [`QueryCommand`]: crate::QueryCommand
38//! [`Session`]: crate::session::Session
39//! [`Conversation`]: crate::conversation::Conversation
40//! [`BudgetTracker`]: crate::budget::BudgetTracker
41//!
42//! # Example
43//!
44//! ```no_run
45//! use claude_wrapper::Claude;
46//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
47//!
48//! # async fn example() -> claude_wrapper::Result<()> {
49//! let claude = Claude::builder().build()?;
50//! let session = DuplexSession::spawn(
51//!     &claude,
52//!     DuplexOptions::default().model("haiku"),
53//! ).await?;
54//!
55//! let turn = session.send("hello").await?;
56//! if let Some(text) = turn.result_text() {
57//!     println!("{text}");
58//! }
59//!
60//! session.close().await?;
61//! # Ok(())
62//! # }
63//! ```
64//!
65//! # Subscribers
66//!
67//! For event-driven UIs that want to react to assistant tokens,
68//! tool-use blocks, or system events as they arrive, call
69//! [`DuplexSession::subscribe`] before issuing a [`DuplexSession::send`].
70//! Each receiver gets its own buffered view of the event stream;
71//! slow consumers see [`tokio::sync::broadcast::error::RecvError::Lagged`]
72//! rather than blocking the session task.
73//!
74//! ```no_run
75//! use claude_wrapper::Claude;
76//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};
77//!
78//! # async fn example() -> claude_wrapper::Result<()> {
79//! let claude = Claude::builder().build()?;
80//! let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
81//!
82//! let mut rx = session.subscribe();
83//! let _turn = session.send("hello").await?;
84//!
85//! while let Ok(event) = rx.try_recv() {
86//!     match event {
87//!         InboundEvent::SystemInit { session_id } => {
88//!             println!("session id: {session_id}");
89//!         }
90//!         InboundEvent::Assistant(_) => {
91//!             // partial or complete assistant message
92//!         }
93//!         _ => {}
94//!     }
95//! }
96//!
97//! session.close().await?;
98//! # Ok(())
99//! # }
100//! ```
101//!
102//! For interleaved (concurrent) event handling while a turn is in
103//! flight, drive `rx.recv()` and the `send()` future together via
104//! `tokio::select!`. Pin the send future and use a block scope so
105//! its borrow of the session ends before [`DuplexSession::close`].
106//!
107//! # Mid-turn permission decisions
108//!
109//! Configure a [`PermissionHandler`] at spawn time to answer the
110//! CLI's permission prompts in-flight. The session writes
111//! `--permission-prompt-tool stdio` automatically when a handler is
112//! set, so the CLI emits `control_request` messages for tool use
113//! over the duplex channel rather than blocking on a TUI prompt.
114//!
115//! ```no_run
116//! use claude_wrapper::Claude;
117//! use claude_wrapper::duplex::{
118//!     DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
119//! };
120//!
121//! # async fn example() -> claude_wrapper::Result<()> {
122//! let handler = PermissionHandler::new(|req| async move {
123//!     if req.tool_name == "Bash" {
124//!         PermissionDecision::Deny { message: "bash is denied".into() }
125//!     } else {
126//!         PermissionDecision::Allow { updated_input: None }
127//!     }
128//! });
129//!
130//! let claude = Claude::builder().build()?;
131//! let session = DuplexSession::spawn(
132//!     &claude,
133//!     DuplexOptions::default().on_permission(handler),
134//! ).await?;
135//! # Ok(())
136//! # }
137//! ```
138//!
139//! For human-in-the-loop UIs, return [`PermissionDecision::Defer`]
140//! from the handler, capture the [`PermissionRequest::request_id`],
141//! and answer later via [`DuplexSession::respond_to_permission`].
142//!
143//! **Known limitation:** as of claude CLI 2.1.x,
144//! `--permission-prompt-tool stdio` does not cause the CLI to emit
145//! `control_request {subtype: "can_use_tool"}` in
146//! `--print --output-format stream-json` mode. The permission handler
147//! registered here is wire-correct and unit-tested, but will not be
148//! invoked end-to-end until the upstream CLI bug is resolved. Tracked
149//! upstream at
150//! <https://github.com/anthropics/claude-agent-sdk-python/issues/469>.
151//!
152//! # Mid-turn interrupt
153//!
154//! [`DuplexSession::interrupt`] sends a clean
155//! `control_request {subtype: "interrupt"}` to the CLI. The CLI
156//! stops generating, closes the in-flight turn (`send().await`
157//! resolves with the truncated [`TurnResult`]), and answers our
158//! interrupt with a `control_response`. Use this instead of dropping
159//! the session or killing the child when you want to cancel one
160//! turn but keep the conversation going.
161//!
162//! ```no_run
163//! use std::time::Duration;
164//! use claude_wrapper::Claude;
165//! use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
166//!
167//! # async fn example() -> claude_wrapper::Result<()> {
168//! let claude = Claude::builder().build()?;
169//! let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
170//!
171//! let send_fut = session.send("write a long essay about rust");
172//! let interrupt_fut = async {
173//!     tokio::time::sleep(Duration::from_millis(500)).await;
174//!     session.interrupt().await
175//! };
176//!
177//! let (turn, interrupt_result) = tokio::join!(send_fut, interrupt_fut);
178//! let _truncated = turn?;
179//! interrupt_result?;
180//! # Ok(())
181//! # }
182//! ```
183//!
184//! # Phased rollout
185//!
186//! This module rolled out in four PRs tracked in
187//! <https://github.com/joshrotenberg/claude-wrapper/issues/561>:
188//! `spawn`/`send`/`close` (PR 1), `subscribe` (PR 2), mid-turn
189//! permission handling (PR 3), and `interrupt` (PR 4, this one).
190
191use 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
211/// Default capacity of the per-session [`broadcast::Sender`] backing
212/// [`DuplexSession::subscribe`].
213///
214/// Override per-session via [`DuplexOptions::subscriber_capacity`].
215pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
216
217/// A mid-turn permission prompt from the CLI for a single tool
218/// invocation.
219///
220/// Forwarded to the [`PermissionHandler`] registered via
221/// [`DuplexOptions::on_permission`]. Capture
222/// [`Self::request_id`] inside your handler if you intend to return
223/// [`PermissionDecision::Defer`] and answer later via
224/// [`DuplexSession::respond_to_permission`].
225#[derive(Debug, Clone)]
226pub struct PermissionRequest {
227    /// CLI-assigned correlation id. Pass this to
228    /// [`DuplexSession::respond_to_permission`] when deferring.
229    pub request_id: String,
230    /// The tool the model wants to use (e.g. `"Bash"`, `"Edit"`).
231    pub tool_name: String,
232    /// The tool's `input` payload as the model produced it.
233    pub input: Value,
234    /// The full `request` object as sent by the CLI, for fields not
235    /// promoted to typed accessors.
236    pub raw: Value,
237}
238
239/// The decision returned from a [`PermissionHandler`] (or passed to
240/// [`DuplexSession::respond_to_permission`] for deferred decisions).
241///
242/// `Allow` and `Deny` both write a control response to the CLI
243/// immediately. `Defer` causes the run loop to skip writing a
244/// response; the caller is then expected to invoke
245/// [`DuplexSession::respond_to_permission`] later. Passing `Defer`
246/// to `respond_to_permission` is a no-op.
247#[derive(Debug, Clone)]
248pub enum PermissionDecision {
249    /// Allow the tool to run, optionally with rewritten input.
250    Allow {
251        /// Replace the model's input with this object before running
252        /// the tool. `None` keeps the original input.
253        updated_input: Option<Value>,
254    },
255    /// Deny the tool. The `message` is surfaced to the model.
256    Deny {
257        /// Human-readable explanation given back to the model.
258        message: String,
259    },
260    /// Decision pending; the caller will supply it later via
261    /// [`DuplexSession::respond_to_permission`].
262    Defer,
263}
264
265type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
266type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
267
268/// A user-supplied async callback invoked when the CLI requests
269/// permission to use a tool.
270///
271/// Construct with [`Self::new`], passing an `async fn` or
272/// async-block closure. Cheap to clone (`Arc` under the hood).
273///
274/// The handler runs inline on the duplex session's task. The CLI is
275/// blocked on the response while the handler runs, so awaiting an
276/// async policy check (DB lookup, remote call) is fine. If the
277/// decision needs human input on a different timescale, return
278/// [`PermissionDecision::Defer`] and answer via
279/// [`DuplexSession::respond_to_permission`] when ready.
280#[derive(Clone)]
281pub struct PermissionHandler {
282    inner: Arc<PermissionFn>,
283}
284
285impl PermissionHandler {
286    /// Wrap an async closure as a permission handler.
287    ///
288    /// # Example
289    ///
290    /// ```
291    /// use claude_wrapper::duplex::{PermissionDecision, PermissionHandler};
292    ///
293    /// let _handler = PermissionHandler::new(|req| async move {
294    ///     if req.tool_name == "Bash" {
295    ///         PermissionDecision::Deny { message: "no bash".into() }
296    ///     } else {
297    ///         PermissionDecision::Allow { updated_input: None }
298    ///     }
299    /// });
300    /// ```
301    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/// Configuration for [`DuplexSession::spawn`].
323///
324/// Builder methods cover the most common spawn-time options. The
325/// spawn call always includes
326/// `--print --verbose --input-format stream-json --output-format stream-json`
327/// regardless of these options.
328#[derive(Debug, Default, Clone)]
329pub struct DuplexOptions {
330    // Spawn-time knobs shared with QueryCommand; the flag emission
331    // lives on SharedSpawnArgs so the two builders cannot drift.
332    shared: SharedSpawnArgs,
333    additional_args: Vec<String>,
334    subscriber_capacity: Option<usize>,
335    on_permission: Option<PermissionHandler>,
336}
337
338impl DuplexOptions {
339    /// Set the model for this session (`--model`).
340    #[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    /// Set the system prompt for this session (`--system-prompt`).
347    #[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    /// Append to the default system prompt (`--append-system-prompt`).
354    #[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    /// Resume a prior session by id (`--resume <session_id>`).
361    ///
362    /// Mirrors [`QueryCommand::resume`](crate::QueryCommand::resume)
363    /// for the duplex path. The spawned `claude` process picks up the
364    /// conversation that produced `session_id` and continues it; turns
365    /// sent through [`DuplexSession::send`] append to the existing
366    /// history rather than starting fresh.
367    ///
368    /// Use case: a host (IDE, MCP server, agent backend) wants to
369    /// upgrade a passive on-disk session to a live duplex one --
370    /// pulls the `session_id` out of the existing JSONL log, opens a
371    /// duplex session here, and the next turn extends the same
372    /// conversation.
373    ///
374    /// `resume` and [`Self::continue_session`] are mutually exclusive
375    /// at the CLI; passing both lets the CLI decide (it errors today).
376    #[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    /// Continue the most recent session in the current working
383    /// directory (`--continue`).
384    ///
385    /// Mirrors [`QueryCommand::continue_session`](crate::QueryCommand::continue_session)
386    /// for the duplex path. Use [`Self::resume`] to pick a specific
387    /// session id; use this when "the last one" is what you want.
388    #[must_use]
389    pub fn continue_session(mut self) -> Self {
390        self.shared.continue_session = true;
391        self
392    }
393
394    /// Run this session in a fresh git worktree (`--worktree [name]`).
395    ///
396    /// `name` is the optional worktree name (the CLI auto-generates
397    /// one if omitted). Calling this method always enables the
398    /// worktree flag, with or without a name.
399    ///
400    /// Use case: an agent host wants the chat's writes isolated from
401    /// the current working tree -- the chat opens with a fresh
402    /// worktree, mutations land there, and the host can inspect or
403    /// merge later.
404    #[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    /// Pin the session to a named subagent (`--agent <name>`).
414    ///
415    /// `name` is resolved by the CLI in this order: inline
416    /// definitions from [`Self::agents_json`], then user-level
417    /// `~/.claude/agents/<name>.md` files, then project-level dirs
418    /// loaded by the active `--setting-sources`.
419    ///
420    /// **Caveat**: as of Claude Code 2.1.143, the CLI silently
421    /// ignores an unknown `name` and falls back to the default
422    /// behavior -- no warning, no error. Callers that want a hard
423    /// "agent must exist" semantics should validate the name out of
424    /// band (e.g. via [`crate::artifacts::AgentsRoot::get`]) before
425    /// passing it here.
426    #[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    /// Inline subagent definitions for this session
433    /// (`--agents <json>`).
434    ///
435    /// `json` is a JSON object keyed by agent name, with each value
436    /// carrying at least `description` and `prompt`. Inline
437    /// definitions take precedence over on-disk
438    /// `~/.claude/agents/*.md` of the same name. Pass [`Self::agent`]
439    /// to select which one to use as the session's persona.
440    ///
441    /// Example: `{"reviewer": {"description": "Reviews code",
442    /// "prompt": "You are a code reviewer"}}`.
443    #[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    /// Set the permission mode for this session
450    /// (`--permission-mode <mode>`).
451    ///
452    /// Mirrors [`QueryCommand::permission_mode`](crate::QueryCommand::permission_mode)
453    /// for the duplex path. The default mode (when this method isn't
454    /// called) drops to the CLI's interactive prompt for every
455    /// tool-use approval, which is broken for non-interactive duplex
456    /// sessions -- nothing answers the prompts and the session stalls
457    /// or fails. Call this with [`PermissionMode::AcceptEdits`] for
458    /// the "edit files autonomously" pattern, [`PermissionMode::Plan`]
459    /// for read-only planning, etc.
460    ///
461    /// Bypass mode is a footgun; reach for [`Self::dangerously_skip_permissions`]
462    /// (or, for stricter discipline, [`crate::dangerous::DangerousClient`])
463    /// when you really need it.
464    #[must_use]
465    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
466        self.shared.permission_mode = Some(mode);
467        self
468    }
469
470    /// Pass `--dangerously-skip-permissions` to the spawned session.
471    ///
472    /// Bypasses ALL permission checks -- file edits, bash, network,
473    /// the lot. Use only when you know the session runs in a trusted
474    /// sandbox (a fresh worktree, a container, etc.). For most "run
475    /// autonomously" cases you want [`Self::permission_mode`] with
476    /// [`PermissionMode::AcceptEdits`] instead.
477    #[must_use]
478    pub fn dangerously_skip_permissions(mut self) -> Self {
479        self.shared.dangerously_skip_permissions = true;
480        self
481    }
482
483    /// Start a new session under a caller-chosen id
484    /// (`--session-id <uuid>`).
485    ///
486    /// Mirrors [`QueryCommand::session_id`](crate::QueryCommand::session_id)
487    /// for the duplex path. Unlike [`Self::resume`] (pick up an
488    /// existing session) or [`Self::continue_session`] (pick up the
489    /// most recent one), this mints a fresh session whose id the host
490    /// knows up front -- useful when the host indexes sessions
491    /// externally before the first turn completes.
492    #[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    /// Set a JSON schema for structured output validation
499    /// (`--json-schema <schema>`).
500    ///
501    /// Mirrors [`QueryCommand::json_schema`](crate::QueryCommand::json_schema)
502    /// for the duplex path. `schema` is the inline JSON of the schema;
503    /// the turn's closing result message carries the validated
504    /// `structured_output`.
505    #[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    /// Add allowed tool patterns (`--allowed-tools`).
512    ///
513    /// Mirrors [`QueryCommand::allowed_tools`](crate::QueryCommand::allowed_tools)
514    /// for the duplex path: accepts anything convertible into
515    /// [`ToolPattern`], including bare strings (e.g. `"Bash"`,
516    /// `"Bash(git log:*)"`, `"mcp__my-server__*"`), and joins them
517    /// into the comma-separated form the CLI expects.
518    #[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    /// Add a single allowed tool pattern.
531    #[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    /// Add disallowed tool patterns (`--disallowed-tools`).
538    #[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    /// Add a single disallowed tool pattern.
551    #[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    /// Cap the number of agentic turns (`--max-turns <n>`).
558    ///
559    /// The cap applies per turn sent through [`DuplexSession::send`];
560    /// a turn that exhausts it closes with an `error_max_turns`
561    /// result rather than an assistant reply.
562    #[must_use]
563    pub fn max_turns(mut self, turns: u32) -> Self {
564        self.shared.max_turns = Some(turns);
565        self
566    }
567
568    /// Cap claude's own spend for the session
569    /// (`--max-budget-usd <usd>`).
570    ///
571    /// This is the CLI's cap, checked post-hoc after each API call,
572    /// so a session can overspend before tripping. It is distinct
573    /// from the wrapper's [`BudgetTracker`](crate::budget::BudgetTracker)
574    /// ceiling, which gates dispatch host-side -- attach one via
575    /// [`Conversation::with_budget`](crate::conversation::Conversation::with_budget)
576    /// to stop a duplex conversation before the next turn is sent.
577    #[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    /// Set a fallback model for when the primary is overloaded
584    /// (`--fallback-model <model>`).
585    #[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    /// Set the reasoning effort level (`--effort <level>`).
592    #[must_use]
593    pub fn effort(mut self, effort: Effort) -> Self {
594        self.shared.effort = Some(effort);
595        self
596    }
597
598    /// Add an additional directory for tool access
599    /// (`--add-dir <dir>`, repeatable).
600    #[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    /// Add an MCP config file path (`--mcp-config <path>`,
607    /// repeatable).
608    ///
609    /// Pair with [`crate::McpConfigBuilder`] to generate the file.
610    #[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    /// Only use MCP servers from `--mcp-config` files, ignoring the
617    /// user- and project-level MCP configuration
618    /// (`--strict-mcp-config`).
619    #[must_use]
620    pub fn strict_mcp_config(mut self) -> Self {
621        self.shared.strict_mcp_config = true;
622        self
623    }
624
625    /// Do not persist the session to on-disk history
626    /// (`--no-session-persistence`).
627    #[must_use]
628    pub fn no_session_persistence(mut self) -> Self {
629        self.shared.no_session_persistence = true;
630        self
631    }
632
633    /// Add a raw argument to the spawn command line.
634    ///
635    /// Escape hatch for flags not covered by the dedicated builder
636    /// methods.
637    #[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    /// Set the per-session [`broadcast::Sender`] capacity backing
644    /// [`DuplexSession::subscribe`].
645    ///
646    /// Defaults to [`DEFAULT_SUBSCRIBER_CAPACITY`] (256). Larger
647    /// values give slow subscribers more room before they
648    /// [`Lagged`](tokio::sync::broadcast::error::RecvError::Lagged);
649    /// smaller values reclaim memory if you do not subscribe.
650    #[must_use]
651    pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
652        self.subscriber_capacity = Some(capacity);
653        self
654    }
655
656    /// Register a [`PermissionHandler`] to answer the CLI's tool-use
657    /// permission prompts in-flight.
658    ///
659    /// When set, the spawn command line includes
660    /// `--permission-prompt-tool stdio`, which configures the CLI to
661    /// emit `control_request` messages for tool use over the duplex
662    /// channel rather than blocking on a TUI prompt.
663    ///
664    /// Without a handler, the session does not pass
665    /// `--permission-prompt-tool` and the CLI applies its default
666    /// permission policy (driven by `--permission-mode`).
667    ///
668    /// **Known limitation:** as of claude CLI 2.1.x the CLI does not
669    /// emit `control_request {subtype: "can_use_tool"}` in stream-json
670    /// print mode, so this handler will not be invoked end-to-end until
671    /// an upstream fix lands. The wire handling is correct; see
672    /// <https://github.com/anthropics/claude-agent-sdk-python/issues/469>.
673    #[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/// The result of one turn through a [`DuplexSession`].
702///
703/// `result` is the raw JSON of the `{"type": "result", ...}` message
704/// that closed the turn. `events` carries every other message
705/// received during the turn (system, assistant, stream_event, user)
706/// in arrival order, with the closing `result` excluded.
707#[derive(Debug, Clone)]
708pub struct TurnResult {
709    /// The raw `{"type": "result", ...}` message that ended the turn.
710    pub result: Value,
711    /// Every other message received during the turn, in order.
712    pub events: Vec<Value>,
713}
714
715impl TurnResult {
716    /// Extract `result.result` as a string, if present.
717    #[must_use]
718    pub fn result_text(&self) -> Option<&str> {
719        self.result.get("result").and_then(Value::as_str)
720    }
721
722    /// Extract `result.session_id`, if present.
723    #[must_use]
724    pub fn session_id(&self) -> Option<&str> {
725        self.result.get("session_id").and_then(Value::as_str)
726    }
727
728    /// Extract `total_cost_usd` (preferred) or the legacy `cost_usd`
729    /// field, if either is present.
730    ///
731    /// This is the cost of one turn. For the conversation-wide
732    /// running total, record turns through a
733    /// [`Conversation`](crate::conversation::Conversation).
734    #[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    /// Extract `duration_ms`, if present.
743    #[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/// A classified inbound event broadcast to [`DuplexSession::subscribe`]
750/// receivers.
751///
752/// Every non-`result` message coming back from the CLI is broadcast as
753/// one of these variants. The closing `{"type": "result"}` message is
754/// not broadcast; it resolves the in-flight [`DuplexSession::send`]
755/// future and lands in [`TurnResult::result`].
756///
757/// Subscribers see the same set of events that accumulate in
758/// [`TurnResult::events`], in the same order, just classified. Adding
759/// a typed accessor for a new event type later (e.g. promoting a
760/// `system` subtype into its own variant) is non-breaking against the
761/// `Other` fallback.
762#[derive(Debug, Clone)]
763pub enum InboundEvent {
764    /// First `{"type": "system", "subtype": "init"}` event for the
765    /// session. Carries the CLI-assigned `session_id`.
766    SystemInit {
767        /// The CLI-assigned session id, useful for logging or
768        /// future resume support.
769        session_id: String,
770    },
771    /// `{"type": "assistant", ...}` -- either a complete assistant
772    /// message or, in stream-json mode, a partial chunk.
773    Assistant(Value),
774    /// `{"type": "stream_event", ...}` -- low-level streaming event
775    /// emitted while a turn is in progress.
776    StreamEvent(Value),
777    /// `{"type": "user", ...}` -- typically a tool result echo from
778    /// the CLI side.
779    User(Value),
780    /// Any other event type, including non-`init` `system` events
781    /// and any message types not yet recognised by this enum.
782    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/// Liveness state of a [`DuplexSession`]'s background task.
805///
806/// Surfaced through [`DuplexSession::is_alive`],
807/// [`DuplexSession::exit_status`], and
808/// [`DuplexSession::wait_for_exit`] for service-shaped hosts that
809/// want non-consuming visibility into whether a session is still
810/// usable. The closing [`DuplexSession::close`] still returns the
811/// full [`Result`] for the one caller that consumes the session.
812///
813/// `Failed` carries a `String` rather than the full
814/// [`Error`] because the underlying watch channel requires `Clone`
815/// and `Error` is not `Clone` (its `Io` variant wraps a non-`Clone`
816/// `std::io::Error`). The full error remains available via
817/// [`DuplexSession::close`].
818#[derive(Debug, Clone)]
819pub enum SessionExitStatus {
820    /// The session task is still running.
821    Running,
822    /// The session task completed normally (close, stdout EOF without
823    /// error).
824    Completed,
825    /// The session task ended with an error. Carries the error's
826    /// `Display` rendering.
827    Failed(String),
828}
829
830/// A long-lived `claude` subprocess in stream-json duplex mode.
831///
832/// Owns a background task that holds the child open, writes user
833/// messages to its stdin, and reads NDJSON events from its stdout.
834/// One turn at a time: calling [`Self::send`] while another turn is
835/// in flight returns [`Error::DuplexTurnInFlight`].
836///
837/// See the [module docs](crate::duplex) for the full design.
838#[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    /// Spawn a fresh `claude` subprocess in duplex mode.
863    ///
864    /// The child is started with
865    /// `--print --verbose --input-format stream-json --output-format stream-json`
866    /// plus any options applied via `opts`. The session task takes
867    /// ownership of the child; dropping the returned handle (or
868    /// calling [`Self::close`]) shuts the task down.
869    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    /// Send one user message and await the closing result event.
931    ///
932    /// Returns [`Error::DuplexTurnInFlight`] if another turn is
933    /// already pending, and [`Error::DuplexClosed`] if the session
934    /// task has already exited.
935    ///
936    /// The returned [`TurnResult`] is per-turn; nothing accumulates
937    /// across calls. For cumulative cost/history and a budget
938    /// ceiling, send through a
939    /// [`Conversation`](crate::conversation::Conversation) instead.
940    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    /// Subscribe to the session's classified inbound event stream.
952    ///
953    /// Returns a [`broadcast::Receiver<InboundEvent>`] that receives
954    /// every non-`result` event as it arrives. Each subscriber gets
955    /// its own buffered view; subscribers added later miss earlier
956    /// events. Slow subscribers see
957    /// [`RecvError::Lagged`](tokio::sync::broadcast::error::RecvError::Lagged)
958    /// rather than blocking the session task.
959    ///
960    /// Subscribers see the same events that accumulate in
961    /// [`TurnResult::events`], in the same order.
962    ///
963    /// # Example
964    ///
965    /// ```no_run
966    /// use claude_wrapper::Claude;
967    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession, InboundEvent};
968    ///
969    /// # async fn example() -> claude_wrapper::Result<()> {
970    /// let claude = Claude::builder().build()?;
971    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
972    /// let mut rx = session.subscribe();
973    ///
974    /// // Subscribe before send so we receive every event.
975    /// let _turn = session.send("hello").await?;
976    ///
977    /// while let Ok(event) = rx.try_recv() {
978    ///     if let InboundEvent::SystemInit { session_id } = event {
979    ///         println!("session id: {session_id}");
980    ///     }
981    /// }
982    /// # Ok(())
983    /// # }
984    /// ```
985    #[must_use]
986    pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
987        self.events_tx.subscribe()
988    }
989
990    /// Cheap, non-blocking liveness check.
991    ///
992    /// Returns `true` while the session task is running, `false` once
993    /// it has exited (whether normally or with an error). Multiple
994    /// concurrent callers are allowed, and the call does not consume
995    /// the session: [`Self::close`] still works after polling.
996    ///
997    /// Reads the latest value from a `tokio::sync::watch` channel
998    /// updated from inside the session task, so it never blocks and
999    /// reflects state set just before the task returns.
1000    #[must_use]
1001    pub fn is_alive(&self) -> bool {
1002        matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
1003    }
1004
1005    /// Snapshot the session task's [`SessionExitStatus`].
1006    ///
1007    /// Returns [`SessionExitStatus::Running`] while the task is still
1008    /// alive, [`SessionExitStatus::Completed`] after a clean exit, or
1009    /// [`SessionExitStatus::Failed`] with the underlying error
1010    /// rendered to a string.
1011    ///
1012    /// Like [`Self::is_alive`], this is a cheap non-blocking read.
1013    #[must_use]
1014    pub fn exit_status(&self) -> SessionExitStatus {
1015        self.exit_rx.borrow().clone()
1016    }
1017
1018    /// Block until the session task transitions out of
1019    /// [`SessionExitStatus::Running`] and return the terminal status.
1020    ///
1021    /// Returns immediately if the task has already exited. Multiple
1022    /// concurrent callers are supported (each gets its own receiver
1023    /// clone), and the call does not consume the session.
1024    ///
1025    /// If the underlying watch sender is dropped without ever
1026    /// publishing a terminal state -- which should not happen in
1027    /// practice, but is treated defensively -- this returns the last
1028    /// observed value.
1029    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    /// Answer a deferred permission request from a different task.
1045    ///
1046    /// Use this after the [`PermissionHandler`] returned
1047    /// [`PermissionDecision::Defer`] for the matching `request_id`.
1048    /// Passing `decision = PermissionDecision::Defer` here is a
1049    /// no-op (logged at `warn`); pass `Allow` or `Deny`.
1050    ///
1051    /// Returns [`Error::DuplexClosed`] if the session task has
1052    /// already exited.
1053    ///
1054    /// # Example
1055    ///
1056    /// ```no_run
1057    /// use claude_wrapper::Claude;
1058    /// use claude_wrapper::duplex::{
1059    ///     DuplexOptions, DuplexSession, PermissionDecision, PermissionHandler,
1060    /// };
1061    /// use tokio::sync::mpsc;
1062    ///
1063    /// # async fn example() -> claude_wrapper::Result<()> {
1064    /// // Forward request_ids out to a UI thread; answer asynchronously.
1065    /// let (tx, _rx) = mpsc::unbounded_channel::<String>();
1066    /// let handler = PermissionHandler::new(move |req| {
1067    ///     let tx = tx.clone();
1068    ///     async move {
1069    ///         let _ = tx.send(req.request_id);
1070    ///         PermissionDecision::Defer
1071    ///     }
1072    /// });
1073    ///
1074    /// let claude = Claude::builder().build()?;
1075    /// let session = DuplexSession::spawn(
1076    ///     &claude,
1077    ///     DuplexOptions::default().on_permission(handler),
1078    /// ).await?;
1079    ///
1080    /// // ...later, from the UI thread:
1081    /// session.respond_to_permission(
1082    ///     "req-abc",
1083    ///     PermissionDecision::Allow { updated_input: None },
1084    /// )?;
1085    /// # Ok(())
1086    /// # }
1087    /// ```
1088    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    /// Send a clean interrupt to the CLI and wait for its
1107    /// acknowledgment.
1108    ///
1109    /// Writes a `control_request {subtype: "interrupt"}` and resolves
1110    /// when the matching `control_response` comes back. The
1111    /// in-flight turn (if any) closes shortly after with a truncated
1112    /// [`TurnResult`] -- the [`DuplexSession::send`] future for it
1113    /// resolves independently. Either ordering is possible; await
1114    /// both via `tokio::join!` if you care about both outcomes.
1115    ///
1116    /// Returns:
1117    /// - `Ok(())` when the CLI acknowledges with `subtype: "success"`.
1118    /// - [`Error::DuplexControlFailed`] when the CLI answers with an
1119    ///   error payload.
1120    /// - [`Error::DuplexClosed`] if the session task exited before
1121    ///   the response arrived.
1122    ///
1123    /// # Example
1124    ///
1125    /// ```no_run
1126    /// use std::time::Duration;
1127    /// use claude_wrapper::Claude;
1128    /// use claude_wrapper::duplex::{DuplexOptions, DuplexSession};
1129    ///
1130    /// # async fn example() -> claude_wrapper::Result<()> {
1131    /// let claude = Claude::builder().build()?;
1132    /// let session = DuplexSession::spawn(&claude, DuplexOptions::default()).await?;
1133    ///
1134    /// let send_fut = session.send("a question that triggers tool use");
1135    /// let interrupt_fut = async {
1136    ///     tokio::time::sleep(Duration::from_millis(250)).await;
1137    ///     session.interrupt().await
1138    /// };
1139    ///
1140    /// let (turn, interrupt) = tokio::join!(send_fut, interrupt_fut);
1141    /// let _truncated = turn?;
1142    /// interrupt?;
1143    /// # Ok(())
1144    /// # }
1145    /// ```
1146    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    /// Close the session and wait for the underlying task to exit.
1155    ///
1156    /// Drops the outbound channel sender, which the session task
1157    /// observes as `recv() -> None`, then closes stdin and reaps the
1158    /// child.
1159    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
1174/// Time budget for the graceful child shutdown after the run loop
1175/// exits. If the child is still alive after this deadline we SIGKILL
1176/// it so close() does not hang on a misbehaving subprocess.
1177const 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
1328/// Action returned from [`handle_inbound`] for the run loop to act
1329/// on after the side-effects (broadcast, accumulate, resolve) are
1330/// done.
1331enum InboundAction {
1332    /// No further action -- side-effects were all handled inline.
1333    None,
1334    /// A `control_request {subtype: "can_use_tool"}` was received and
1335    /// needs the [`PermissionHandler`] invoked. The run loop awaits
1336    /// the handler and writes the response.
1337    Permission(PermissionRequest),
1338    /// A `control_response` matching one of our outbound
1339    /// `control_request`s arrived. The run loop matches `request_id`
1340    /// against its `pending_control` table and resolves the
1341    /// corresponding oneshot.
1342    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            // can_use_tool flows through the permission handler;
1367            // anything else is logged + accumulated as Other for now.
1368            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            // Broadcast a classified copy. Send error means no
1409            // subscribers, which is fine -- subscribers are optional.
1410            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
1435/// Pull `(request_id, outcome)` out of a `control_response` envelope.
1436///
1437/// Returns `None` if `request_id` is missing or the subtype is
1438/// unrecognised. `Some((id, Ok(())))` for `subtype: "success"`,
1439/// `Some((id, Err(DuplexControlFailed)))` for `subtype: "error"`.
1440fn 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            // Caller path is supposed to filter this; defensive guard.
1503            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        // Last two entries should be the additional args, in order.
1588        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        // No name means no positional follows --worktree.
1623        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        // Same `--` ordering bug class as resume.
1651        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        // Both flags present.
1704        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    // -- QueryCommand knob-set parity (#672) -------------------------
1739
1740    #[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        // Same `--` ordering bug class as resume/agent.
1824        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        // Catches the same class of bug as QueryCommand::execute_json
1867        // had: a flag appended after the user-supplied raw args (which
1868        // typically include `--`) gets eaten as a positional. Resume
1869        // must precede any caller-injected `arg(...)`.
1870        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        // Result is not broadcast -- it lands in TurnResult.result.
1994        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        // subscriber_capacity is runtime config, not a CLI arg.
2054        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        // Event should also be accumulated in the pending turn.
2204        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        // Both handles invoke the same underlying closure.
2369        let _ = handler.invoke(req.clone()).await;
2370        let _ = cloned.invoke(req).await;
2371    }
2372
2373    /// Build a `DuplexSession` whose channels are wired up but whose
2374    /// background task is a no-op. Tests can drive the watch state
2375    /// machine via the returned `exit_tx` and observe the public
2376    /// accessors. The fake task idles on a oneshot so it stays alive
2377    /// for the life of the test (no JoinHandle::abort handshake
2378    /// needed).
2379    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        // Defensive: if exit_tx is dropped without ever publishing a
2505        // terminal value, wait_for_exit should fall back to the last
2506        // observed state rather than hang.
2507        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}