use std::collections::{HashMap, VecDeque};
use std::future::Future;
use std::pin::Pin;
use std::process::{ExitStatus, Stdio};
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use tokio::task::JoinHandle;
use tokio::time::Instant;
use tracing::{Instrument, debug, warn};
use crate::Claude;
use crate::command::spawn_args::{SharedSpawnArgs, shell_quote};
use crate::error::{DuplexLimit, DuplexOperation, Error, Result};
use crate::tool_pattern::ToolPattern;
use crate::types::{Effort, HermeticScope, PermissionMode};
pub const DEFAULT_SUBSCRIBER_CAPACITY: usize = 256;
#[derive(Debug, Clone)]
pub struct PermissionRequest {
pub request_id: String,
pub tool_name: String,
pub input: Value,
pub raw: Value,
}
#[derive(Debug, Clone)]
pub enum PermissionDecision {
Allow {
updated_input: Option<Value>,
},
Deny {
message: String,
},
Defer,
}
type PermissionFuture = Pin<Box<dyn Future<Output = PermissionDecision> + Send + 'static>>;
type PermissionFn = dyn Fn(PermissionRequest) -> PermissionFuture + Send + Sync + 'static;
#[derive(Clone)]
pub struct PermissionHandler {
inner: Arc<PermissionFn>,
}
impl PermissionHandler {
pub fn new<F, Fut>(f: F) -> Self
where
F: Fn(PermissionRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = PermissionDecision> + Send + 'static,
{
Self {
inner: Arc::new(move |req| Box::pin(f(req))),
}
}
fn invoke(&self, req: PermissionRequest) -> PermissionFuture {
(self.inner)(req)
}
}
impl std::fmt::Debug for PermissionHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PermissionHandler").finish_non_exhaustive()
}
}
#[derive(Debug, Default, Clone)]
pub struct DuplexOptions {
shared: SharedSpawnArgs,
additional_args: Vec<String>,
subscriber_capacity: Option<usize>,
on_permission: Option<PermissionHandler>,
include_partial_messages: bool,
policy: DuplexPolicy,
}
#[derive(Debug, Default, Clone, Copy)]
struct DuplexPolicy {
max_frame_bytes: Option<usize>,
max_turn_bytes: Option<usize>,
max_turn_events: Option<usize>,
turn_timeout: Option<Duration>,
control_timeout: Option<Duration>,
}
impl DuplexOptions {
#[must_use]
pub fn model(mut self, model: impl Into<String>) -> Self {
self.shared.model = Some(model.into());
self
}
#[must_use]
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.shared.system_prompt = Some(prompt.into());
self
}
#[must_use]
pub fn append_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.shared.append_system_prompt = Some(prompt.into());
self
}
#[must_use]
pub fn resume(mut self, session_id: impl Into<String>) -> Self {
self.shared.resume = Some(session_id.into());
self
}
#[must_use]
pub fn continue_session(mut self) -> Self {
self.shared.continue_session = true;
self
}
#[must_use]
pub fn worktree(mut self, name: Option<impl Into<String>>) -> Self {
self.shared.worktree = true;
if let Some(n) = name {
self.shared.worktree_name = Some(n.into());
}
self
}
#[must_use]
pub fn agent(mut self, name: impl Into<String>) -> Self {
self.shared.agent = Some(name.into());
self
}
#[must_use]
pub fn agents_json(mut self, json: impl Into<String>) -> Self {
self.shared.agents_json = Some(json.into());
self
}
#[must_use]
pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
self.shared.permission_mode = Some(mode);
self
}
#[must_use]
pub fn forward_subagent_text(mut self) -> Self {
self.shared.forward_subagent_text = true;
self
}
#[must_use]
pub fn allow_dangerously_skip_permissions(mut self) -> Self {
self.shared.allow_dangerously_skip_permissions = true;
self
}
#[must_use]
pub fn dangerously_skip_permissions(mut self) -> Self {
self.shared.dangerously_skip_permissions = true;
self
}
#[must_use]
pub fn session_id(mut self, id: impl Into<String>) -> Self {
self.shared.session_id = Some(id.into());
self
}
#[must_use]
pub fn json_schema(mut self, schema: impl Into<String>) -> Self {
self.shared.json_schema = Some(schema.into());
self
}
#[must_use]
pub fn allowed_tools<I, T>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<ToolPattern>,
{
self.shared
.allowed_tools
.extend(tools.into_iter().map(Into::into));
self
}
#[must_use]
pub fn allowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
self.shared.allowed_tools.push(tool.into());
self
}
#[must_use]
pub fn disallowed_tools<I, T>(mut self, tools: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<ToolPattern>,
{
self.shared
.disallowed_tools
.extend(tools.into_iter().map(Into::into));
self
}
#[must_use]
pub fn disallowed_tool(mut self, tool: impl Into<ToolPattern>) -> Self {
self.shared.disallowed_tools.push(tool.into());
self
}
#[must_use]
pub fn max_turns(mut self, turns: u32) -> Self {
self.shared.max_turns = Some(turns);
self
}
#[must_use]
pub fn max_budget_usd(mut self, budget: f64) -> Self {
self.shared.max_budget_usd = Some(budget);
self
}
#[must_use]
pub fn fallback_model(mut self, model: impl Into<String>) -> Self {
self.shared.fallback_model = Some(model.into());
self
}
#[must_use]
pub fn effort(mut self, effort: Effort) -> Self {
self.shared.effort = Some(effort);
self
}
#[must_use]
pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
self.shared.add_dir.push(dir.into());
self
}
#[must_use]
pub fn mcp_config(mut self, path: impl Into<String>) -> Self {
self.shared.mcp_config.push(path.into());
self
}
#[must_use]
pub fn strict_mcp_config(mut self) -> Self {
self.shared.strict_mcp_config = true;
self
}
#[must_use]
pub fn setting_sources(mut self, sources: impl Into<String>) -> Self {
self.shared.setting_sources = Some(sources.into());
self
}
#[must_use]
pub fn hermetic(mut self) -> Self {
self.shared.apply_hermetic(HermeticScope::Full);
self
}
#[must_use]
pub fn hermetic_scoped(mut self, scope: HermeticScope) -> Self {
self.shared.apply_hermetic(scope);
self
}
#[must_use]
pub fn no_session_persistence(mut self) -> Self {
self.shared.no_session_persistence = true;
self
}
#[must_use]
pub fn tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.shared.tools.extend(tools.into_iter().map(Into::into));
self
}
#[must_use]
pub fn file(mut self, spec: impl Into<String>) -> Self {
self.shared.file.push(spec.into());
self
}
#[must_use]
pub fn settings(mut self, settings: impl Into<String>) -> Self {
self.shared.settings = Some(settings.into());
self
}
#[must_use]
pub fn fork_session(mut self) -> Self {
self.shared.fork_session = true;
self
}
#[must_use]
pub fn debug_filter(mut self, filter: impl Into<String>) -> Self {
self.shared.debug_filter = Some(filter.into());
self
}
#[must_use]
pub fn debug_file(mut self, path: impl Into<String>) -> Self {
self.shared.debug_file = Some(path.into());
self
}
#[must_use]
pub fn betas(mut self, betas: impl Into<String>) -> Self {
self.shared.betas = Some(betas.into());
self
}
#[must_use]
pub fn plugin_dir(mut self, dir: impl Into<String>) -> Self {
self.shared.plugin_dirs.push(dir.into());
self
}
#[must_use]
pub fn plugin_url(mut self, url: impl Into<String>) -> Self {
self.shared.plugin_urls.push(url.into());
self
}
#[must_use]
pub fn tmux(mut self) -> Self {
self.shared.tmux = true;
self
}
#[must_use]
pub fn bare(mut self) -> Self {
self.shared.bare = true;
self
}
#[must_use]
pub fn safe_mode(mut self) -> Self {
self.shared.safe_mode = true;
self
}
#[must_use]
pub fn disable_slash_commands(mut self) -> Self {
self.shared.disable_slash_commands = true;
self
}
#[must_use]
pub fn include_hook_events(mut self) -> Self {
self.shared.include_hook_events = true;
self
}
#[must_use]
pub fn exclude_dynamic_system_prompt_sections(mut self) -> Self {
self.shared.exclude_dynamic_system_prompt_sections = true;
self
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.shared.name = Some(name.into());
self
}
#[must_use]
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.additional_args.push(arg.into());
self
}
#[must_use]
pub fn subscriber_capacity(mut self, capacity: usize) -> Self {
self.subscriber_capacity = Some(capacity);
self
}
#[must_use]
pub fn on_permission(mut self, handler: PermissionHandler) -> Self {
self.on_permission = Some(handler);
self
}
#[must_use]
pub fn include_partial_messages(mut self) -> Self {
self.include_partial_messages = true;
self
}
#[must_use]
pub fn max_frame_bytes(mut self, bytes: usize) -> Self {
self.policy.max_frame_bytes = Some(bytes);
self
}
#[must_use]
pub fn max_turn_bytes(mut self, bytes: usize) -> Self {
self.policy.max_turn_bytes = Some(bytes);
self
}
#[must_use]
pub fn max_turn_events(mut self, events: usize) -> Self {
self.policy.max_turn_events = Some(events);
self
}
#[must_use]
pub fn turn_timeout(mut self, timeout: Duration) -> Self {
self.policy.turn_timeout = Some(timeout);
self
}
#[must_use]
pub fn control_timeout(mut self, timeout: Duration) -> Self {
self.policy.control_timeout = Some(timeout);
self
}
fn build_args(&self) -> Vec<String> {
let mut args = vec![
"--print".to_string(),
"--verbose".to_string(),
"--output-format".to_string(),
"stream-json".to_string(),
"--input-format".to_string(),
"stream-json".to_string(),
];
self.shared.append_to(&mut args);
if self.include_partial_messages {
args.push("--include-partial-messages".to_string());
}
if self.on_permission.is_some() {
args.push("--permission-prompt-tool".to_string());
args.push("stdio".to_string());
}
args.extend(self.additional_args.iter().cloned());
args
}
fn spawn_command_args(&self, claude: &Claude) -> Vec<String> {
let mut args = claude.global_args.clone();
args.extend(self.build_args());
args
}
#[must_use]
pub fn to_command_string(&self, claude: &Claude) -> String {
let args = self.spawn_command_args(claude);
let quoted_args = args.iter().map(|arg| shell_quote(arg)).collect::<Vec<_>>();
format!("{} {}", claude.binary().display(), quoted_args.join(" "))
}
}
#[derive(Debug, Clone)]
pub struct TurnResult {
pub result: Value,
pub events: Vec<Value>,
}
impl TurnResult {
#[must_use]
pub fn result_text(&self) -> Option<&str> {
self.result.get("result").and_then(Value::as_str)
}
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.result.get("session_id").and_then(Value::as_str)
}
#[must_use]
pub fn total_cost_usd(&self) -> Option<f64> {
self.result
.get("total_cost_usd")
.or_else(|| self.result.get("cost_usd"))
.and_then(Value::as_f64)
}
#[must_use]
pub fn duration_ms(&self) -> Option<u64> {
self.result.get("duration_ms").and_then(Value::as_u64)
}
#[must_use]
pub fn subtype(&self) -> Option<&str> {
self.result.get("subtype").and_then(Value::as_str)
}
#[must_use]
pub fn is_error(&self) -> bool {
self.result
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false)
}
}
pub(crate) fn turn_cost_delta(prev_total: &mut Option<f64>, total: Option<f64>) -> Option<f64> {
let total = total?;
let prev = prev_total.replace(total).unwrap_or(0.0);
Some((total - prev).max(0.0))
}
#[derive(Debug, Clone)]
pub enum InboundEvent {
SystemInit {
session_id: String,
},
Assistant(Value),
StreamEvent(Value),
User(Value),
Other(Value),
}
fn classify(msg: &Value) -> InboundEvent {
match msg.get("type").and_then(Value::as_str) {
Some("system") => {
if msg.get("subtype").and_then(Value::as_str) == Some("init")
&& let Some(id) = msg.get("session_id").and_then(Value::as_str)
{
return InboundEvent::SystemInit {
session_id: id.to_string(),
};
}
InboundEvent::Other(msg.clone())
}
Some("assistant") => InboundEvent::Assistant(msg.clone()),
Some("stream_event") => InboundEvent::StreamEvent(msg.clone()),
Some("user") => InboundEvent::User(msg.clone()),
_ => InboundEvent::Other(msg.clone()),
}
}
#[derive(Debug, Clone)]
pub enum SessionExitStatus {
Running,
Completed,
Failed(String),
}
#[derive(Debug)]
pub struct DuplexSession {
outbound_tx: mpsc::UnboundedSender<OutboundMsg>,
events_tx: broadcast::Sender<InboundEvent>,
exit_rx: watch::Receiver<SessionExitStatus>,
join: JoinHandle<Result<()>>,
}
#[derive(Debug)]
enum OutboundMsg {
Send {
prompt: String,
reply: oneshot::Sender<Result<TurnResult>>,
},
PermissionResponse {
request_id: String,
decision: PermissionDecision,
},
Interrupt {
reply: oneshot::Sender<Result<()>>,
},
}
impl DuplexSession {
pub async fn spawn(claude: &Claude, opts: DuplexOptions) -> Result<Self> {
let capacity = opts
.subscriber_capacity
.unwrap_or(DEFAULT_SUBSCRIBER_CAPACITY);
let permission_handler = opts.on_permission.clone();
let command_args = opts.spawn_command_args(claude);
debug!(
arg_count = command_args.len(),
"spawning duplex claude session"
);
let mut cmd = Command::new(&claude.binary);
cmd.args(&command_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
crate::exec::apply_process_group(&mut cmd, claude.process_group);
if let Some(ref dir) = claude.working_dir {
cmd.current_dir(dir);
}
let mut child = crate::exec::spawn_retrying_txtbsy(&mut cmd)
.await
.map_err(|e| Error::Io {
message: format!("failed to spawn claude: {e}"),
source: e,
working_dir: claude.working_dir.clone(),
})?;
let group =
crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
let stdin = child.stdin.take().expect("stdin was piped");
let stdout = child.stdout.take().expect("stdout was piped");
let (outbound_tx, outbound_rx) = mpsc::unbounded_channel();
let (events_tx, _initial_rx) = broadcast::channel(capacity);
let (exit_tx, exit_rx) = watch::channel(SessionExitStatus::Running);
let session_span = tracing::debug_span!(
"claude.session",
model_configured = opts.shared.model.is_some(),
permission_mode = opts
.shared
.permission_mode
.as_ref()
.map(|m| m.as_arg())
.unwrap_or("default"),
resumed = opts.shared.resume.is_some(),
turns = tracing::field::Empty,
exit = tracing::field::Empty,
);
let join = tokio::spawn(
run_session(
child,
group,
claude.kill_grace,
claude.termination_signal,
stdin,
stdout,
outbound_rx,
events_tx.clone(),
permission_handler,
opts.policy,
exit_tx,
)
.instrument(session_span),
);
Ok(Self {
outbound_tx,
events_tx,
exit_rx,
join,
})
}
pub async fn send(&self, prompt: impl Into<String>) -> Result<TurnResult> {
let (reply_tx, reply_rx) = oneshot::channel();
self.outbound_tx
.send(OutboundMsg::Send {
prompt: prompt.into(),
reply: reply_tx,
})
.map_err(|_| Error::DuplexClosed)?;
reply_rx.await.map_err(|_| Error::DuplexClosed)?
}
#[must_use]
pub fn subscribe(&self) -> broadcast::Receiver<InboundEvent> {
self.events_tx.subscribe()
}
#[must_use]
pub fn is_alive(&self) -> bool {
matches!(*self.exit_rx.borrow(), SessionExitStatus::Running)
}
#[must_use]
pub fn exit_status(&self) -> SessionExitStatus {
self.exit_rx.borrow().clone()
}
pub async fn wait_for_exit(&self) -> SessionExitStatus {
let mut rx = self.exit_rx.clone();
loop {
{
let value = rx.borrow_and_update();
if !matches!(*value, SessionExitStatus::Running) {
return value.clone();
}
}
if rx.changed().await.is_err() {
return rx.borrow().clone();
}
}
}
pub fn respond_to_permission(
&self,
request_id: impl Into<String>,
decision: PermissionDecision,
) -> Result<()> {
if matches!(decision, PermissionDecision::Defer) {
warn!("respond_to_permission called with Defer; ignoring");
return Ok(());
}
self.outbound_tx
.send(OutboundMsg::PermissionResponse {
request_id: request_id.into(),
decision,
})
.map_err(|_| Error::DuplexClosed)?;
Ok(())
}
pub async fn interrupt(&self) -> Result<()> {
let (reply_tx, reply_rx) = oneshot::channel();
self.outbound_tx
.send(OutboundMsg::Interrupt { reply: reply_tx })
.map_err(|_| Error::DuplexClosed)?;
reply_rx.await.map_err(|_| Error::DuplexClosed)?
}
pub async fn close(self) -> Result<()> {
drop(self.outbound_tx);
drop(self.events_tx);
match self.join.await {
Ok(result) => result,
Err(e) if e.is_cancelled() => Ok(()),
Err(e) => Err(Error::Io {
message: format!("duplex session task panicked: {e}"),
source: std::io::Error::other(e.to_string()),
working_dir: None,
}),
}
}
}
const SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
const STDERR_TAIL_BYTES: usize = 64 * 1024;
const STDERR_SETTLE_BUDGET: Duration = Duration::from_millis(200);
async fn read_stderr_tail<R: tokio::io::AsyncRead + Unpin>(
reader: &mut R,
tail: &mut VecDeque<u8>,
) -> std::io::Result<()> {
let mut chunk = [0u8; 8192];
loop {
let read = match reader.read(&mut chunk).await {
Ok(0) => return Ok(()),
Ok(read) => read,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
};
let excess = (tail.len() + read).saturating_sub(STDERR_TAIL_BYTES);
tail.drain(..excess);
tail.extend(&chunk[..read]);
}
}
#[allow(clippy::too_many_arguments)]
async fn run_session(
mut child: Child,
group: crate::exec::GroupKillGuard,
kill_grace: Option<Duration>,
termination_signal: crate::TerminationSignal,
stdin: ChildStdin,
stdout: ChildStdout,
outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
events_tx: broadcast::Sender<InboundEvent>,
permission_handler: Option<PermissionHandler>,
policy: DuplexPolicy,
exit_tx: watch::Sender<SessionExitStatus>,
) -> Result<()> {
let mut stderr = child.stderr.take().expect("stderr was piped");
let mut tail = VecDeque::new();
let (stderr_failed_tx, stderr_failed_rx) = oneshot::channel();
let (status, stderr_result) = {
let drain_stderr = async {
let result = read_stderr_tail(&mut stderr, &mut tail).await;
if result.is_err() {
let _ = stderr_failed_tx.send(());
}
result
};
let session = run_session_child(
child,
group,
kill_grace,
termination_signal,
stdin,
stdout,
outbound_rx,
events_tx,
permission_handler,
policy,
stderr_failed_rx,
);
tokio::pin!(drain_stderr);
tokio::pin!(session);
tokio::select! {
status = &mut session => {
let read = tokio::time::timeout(
STDERR_SETTLE_BUDGET, &mut drain_stderr,
).await.ok();
(status, read)
}
read = &mut drain_stderr => (session.await, Some(read)),
}
};
let stderr = String::from_utf8_lossy(&tail.into_iter().collect::<Vec<_>>()).into_owned();
let result = resident_outcome(status, stderr_result, stderr);
let final_state = match &result {
Ok(()) => SessionExitStatus::Completed,
Err(error) => SessionExitStatus::Failed(error.to_string()),
};
tracing::Span::current().record(
"exit",
if result.is_ok() {
"completed"
} else {
"failed"
},
);
let _ = exit_tx.send(final_state);
result
}
fn resident_outcome(
status: Result<(ExitStatus, bool)>,
stderr_result: Option<std::io::Result<()>>,
stderr: String,
) -> Result<()> {
match (status, stderr_result) {
(Err(error), _) => Err(error),
(Ok((status, shutdown_budget_exceeded)), read) if !status.success() => {
Err(Error::DuplexProcessFailed {
status,
shutdown_budget_exceeded,
stderr,
stderr_read_error: read.and_then(std::result::Result::err),
})
}
(Ok(_), Some(Err(source))) => Err(Error::Io {
message: format!("failed to read duplex stderr; retained diagnostics: {stderr}"),
source,
working_dir: None,
}),
(Ok(_), _) => Ok(()),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct OpDeadline {
at: Instant,
operation: DuplexOperation,
timeout: Duration,
}
impl OpDeadline {
fn start(operation: DuplexOperation, timeout: Option<Duration>) -> Option<Self> {
let timeout = timeout?;
Some(Self {
at: Instant::now().checked_add(timeout)?,
operation,
timeout,
})
}
fn failure(self) -> PolicyFailure {
PolicyFailure::Deadline {
operation: self.operation,
timeout: self.timeout,
}
}
}
#[derive(Debug, Clone, Copy)]
enum PolicyFailure {
Limit {
limit: DuplexLimit,
max: usize,
},
Deadline {
operation: DuplexOperation,
timeout: Duration,
},
}
impl PolicyFailure {
fn error(self, process_settled: bool) -> Error {
match self {
Self::Limit { limit, max } => Error::DuplexLimitExceeded {
limit,
max,
process_settled,
},
Self::Deadline { operation, timeout } => Error::DuplexDeadlineExceeded {
operation,
timeout,
process_settled,
},
}
}
}
#[derive(Debug, Default)]
struct TurnBytes {
limit: Option<usize>,
used: Option<usize>,
}
impl TurnBytes {
fn start(&mut self) {
self.used = Some(0);
}
fn stop(&mut self) {
self.used = None;
}
fn charge(&mut self, bytes: usize) -> std::result::Result<(), PolicyFailure> {
if let Some(used) = self.used.as_mut() {
let total = used.saturating_add(bytes);
if let Some(max) = self.limit
&& total > max
{
return Err(PolicyFailure::Limit {
limit: DuplexLimit::TurnBytes,
max,
});
}
*used = total;
}
Ok(())
}
}
enum FrameRead {
Frame(Vec<u8>),
Eof,
}
enum FrameError {
Io(std::io::Error),
Limit(PolicyFailure),
}
struct FrameReader<R> {
reader: BufReader<R>,
partial: Vec<u8>,
max_frame_bytes: Option<usize>,
}
impl<R: tokio::io::AsyncRead + Unpin> FrameReader<R> {
fn new(reader: R, max_frame_bytes: Option<usize>) -> Self {
Self {
reader: BufReader::new(reader),
partial: Vec::new(),
max_frame_bytes,
}
}
async fn next_frame(
&mut self,
turn: &mut TurnBytes,
) -> std::result::Result<FrameRead, FrameError> {
if turn.used == Some(0) {
turn.charge(self.partial.len()).map_err(FrameError::Limit)?;
}
loop {
let available = match self.reader.fill_buf().await {
Ok(available) => available,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(FrameError::Io(error)),
};
if available.is_empty() {
if self.partial.is_empty() {
return Ok(FrameRead::Eof);
}
return Self::validate_frame(std::mem::take(&mut self.partial));
}
let (take, complete) = match available.iter().position(|&byte| byte == b'\n') {
Some(end) => (end + 1, true),
None => (available.len(), false),
};
if let Some(max) = self.max_frame_bytes
&& self.partial.len().saturating_add(take) > max
{
return Err(FrameError::Limit(PolicyFailure::Limit {
limit: DuplexLimit::FrameBytes,
max,
}));
}
turn.charge(take).map_err(FrameError::Limit)?;
self.partial.extend_from_slice(&available[..take]);
self.reader.consume(take);
if complete {
let mut frame = std::mem::take(&mut self.partial);
frame.pop();
if frame.last() == Some(&b'\r') {
frame.pop();
}
return Self::validate_frame(frame);
}
}
}
fn validate_frame(frame: Vec<u8>) -> std::result::Result<FrameRead, FrameError> {
std::str::from_utf8(&frame).map_err(|error| {
FrameError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
})?;
Ok(FrameRead::Frame(frame))
}
fn into_inner(self) -> BufReader<R> {
self.reader
}
}
type PendingTurn = Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)>;
type PendingControl = HashMap<String, (oneshot::Sender<Result<()>>, Option<OpDeadline>)>;
enum IoEnd {
Eof,
Closed,
StderrFailed,
Read(Error),
Policy(PolicyFailure),
}
struct Resident {
stdin: ChildStdin,
frames: FrameReader<ChildStdout>,
turn_bytes: TurnBytes,
pending: PendingTurn,
turn_deadline: Option<OpDeadline>,
pending_control: PendingControl,
next_control_id: u64,
deadline_tx: watch::Sender<Option<OpDeadline>>,
policy: DuplexPolicy,
session_span: tracing::Span,
turns: u64,
turn_span: Option<tracing::Span>,
turn_started: Option<std::time::Instant>,
last_total_cost: Option<f64>,
}
impl Resident {
fn earliest_deadline(&self) -> Option<OpDeadline> {
self.turn_deadline
.into_iter()
.chain(self.pending_control.values().filter_map(|(_, d)| *d))
.min_by_key(|deadline| deadline.at)
}
fn expired_deadline(&self) -> Option<PolicyFailure> {
self.earliest_deadline()
.filter(|deadline| deadline.at <= Instant::now())
.map(OpDeadline::failure)
}
fn publish_deadline(&self) {
let next = self.earliest_deadline();
self.deadline_tx.send_if_modified(|current| {
let changed = *current != next;
*current = next;
changed
});
}
fn finish_turn(&mut self) {
self.turn_deadline = None;
self.turn_bytes.stop();
self.publish_deadline();
}
fn record_turn_outcome(&mut self, result: &Value) {
let total = result
.get("total_cost_usd")
.or_else(|| result.get("cost_usd"))
.and_then(Value::as_f64);
let cost = turn_cost_delta(&mut self.last_total_cost, total);
let Some(span) = self.turn_span.take() else {
return;
};
span.record(
"is_error",
result
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
);
if let Some(sub) = result.get("subtype").and_then(Value::as_str) {
let label = match sub {
"success" => "success",
"error_during_execution" => "error_during_execution",
"error_max_turns" => "error_max_turns",
"error_max_budget_usd" => "error_max_budget_usd",
_ => "other",
};
span.record("subtype", label);
}
if let (Some(c), Some(t)) = (cost, total) {
span.record("cost_usd", c);
span.record("total_cost_usd", t);
}
if let Some(started) = self.turn_started.take() {
span.record("duration_ms", started.elapsed().as_millis() as u64);
}
}
async fn handle_frame(
&mut self,
frame: &[u8],
events_tx: &broadcast::Sender<InboundEvent>,
permission_handler: Option<&PermissionHandler>,
) -> Option<PolicyFailure> {
if frame.iter().all(u8::is_ascii_whitespace) {
return None;
}
let parsed = match serde_json::from_slice::<Value>(frame) {
Ok(v) => v,
Err(e) => {
debug!(line_bytes = frame.len(), json_error = ?e.classify(), "failed to parse duplex event, skipping");
return None;
}
};
if let Some(failure) = self.expired_deadline() {
return Some(failure);
}
let is_result = parsed.get("type").and_then(Value::as_str) == Some("result");
if is_result {
self.record_turn_outcome(&parsed);
}
match handle_inbound(
parsed,
&mut self.pending,
events_tx,
self.policy.max_turn_events,
) {
InboundAction::None => {}
InboundAction::EventLimit(max) => {
return Some(PolicyFailure::Limit {
limit: DuplexLimit::TurnEvents,
max,
});
}
InboundAction::Permission(req) => {
let request_id = req.request_id.clone();
let decision = match permission_handler {
Some(h) => h.invoke(req).await,
None => {
warn!("received can_use_tool with no permission handler; auto-denying");
PermissionDecision::Deny {
message: "no permission handler configured on duplex session".into(),
}
}
};
if matches!(decision, PermissionDecision::Defer) {
debug!("permission handler deferred; waiting for respond_to_permission");
} else if write_permission_response(&mut self.stdin, &request_id, &decision)
.await
.is_err()
{
warn!("failed to write permission response");
}
}
InboundAction::ControlResponse {
request_id,
outcome,
} => {
if let Some((reply, _)) = self.pending_control.remove(&request_id) {
let _ = reply.send(outcome);
self.publish_deadline();
} else {
debug!("received control_response with no pending request");
}
}
}
if is_result && self.pending.is_none() {
self.finish_turn();
}
None
}
async fn handle_outbound(&mut self, msg: OutboundMsg) {
match msg {
OutboundMsg::Send { prompt, reply } => {
if self.pending.is_some() {
let _ = reply.send(Err(Error::DuplexTurnInFlight));
return;
}
self.pending = Some((reply, Vec::new()));
self.turn_deadline =
OpDeadline::start(DuplexOperation::Turn, self.policy.turn_timeout);
self.turn_bytes.start();
self.publish_deadline();
if let Err(e) = write_user(&mut self.stdin, &prompt).await {
if let Some((reply, _)) = self.pending.take() {
let _ = reply.send(Err(e));
}
self.finish_turn();
return;
}
self.turns += 1;
self.turn_span = Some(tracing::debug_span!(
parent: &self.session_span,
"claude.turn",
turn = self.turns,
is_error = tracing::field::Empty,
subtype = tracing::field::Empty,
cost_usd = tracing::field::Empty,
total_cost_usd = tracing::field::Empty,
duration_ms = tracing::field::Empty,
));
self.turn_started = Some(std::time::Instant::now());
}
OutboundMsg::PermissionResponse {
request_id,
decision,
} => {
if write_permission_response(&mut self.stdin, &request_id, &decision)
.await
.is_err()
{
warn!("failed to write deferred permission response");
}
}
OutboundMsg::Interrupt { reply } => {
self.next_control_id += 1;
let request_id = format!("interrupt-{}", self.next_control_id);
let deadline =
OpDeadline::start(DuplexOperation::Control, self.policy.control_timeout);
self.pending_control
.insert(request_id.clone(), (reply, deadline));
self.publish_deadline();
if let Err(e) =
write_control_request(&mut self.stdin, &request_id, "interrupt").await
{
if let Some((reply, _)) = self.pending_control.remove(&request_id) {
let _ = reply.send(Err(e));
}
self.publish_deadline();
}
}
}
}
}
async fn pump(
res: &mut Resident,
outbound_rx: &mut mpsc::UnboundedReceiver<OutboundMsg>,
events_tx: &broadcast::Sender<InboundEvent>,
permission_handler: Option<&PermissionHandler>,
stderr_failed: &mut oneshot::Receiver<()>,
) -> IoEnd {
let mut stderr_failure_open = true;
loop {
if let Some(failure) = res.expired_deadline() {
return IoEnd::Policy(failure);
}
tokio::select! {
frame = res.frames.next_frame(&mut res.turn_bytes) => match frame {
Ok(FrameRead::Frame(frame)) => {
if let Some(failure) =
res.handle_frame(&frame, events_tx, permission_handler).await
{
return IoEnd::Policy(failure);
}
tokio::task::consume_budget().await;
}
Ok(FrameRead::Eof) => return IoEnd::Eof,
Err(FrameError::Io(source)) => {
return IoEnd::Read(Error::Io {
message: "failed to read duplex stdout".to_string(),
source,
working_dir: None,
});
}
Err(FrameError::Limit(failure)) => return IoEnd::Policy(failure),
},
failure = &mut *stderr_failed, if stderr_failure_open => {
stderr_failure_open = false;
if failure.is_ok() {
return IoEnd::StderrFailed;
}
},
msg = outbound_rx.recv() => match msg {
Some(msg) => res.handle_outbound(msg).await,
None => return IoEnd::Closed,
},
}
}
}
async fn expire(deadline: Option<OpDeadline>) -> PolicyFailure {
match deadline {
Some(deadline) => {
tokio::time::sleep_until(deadline.at).await;
deadline.failure()
}
None => std::future::pending().await,
}
}
async fn supervise(
pump: impl Future<Output = IoEnd>,
deadline_rx: &mut watch::Receiver<Option<OpDeadline>>,
) -> IoEnd {
tokio::pin!(pump);
let mut deadlines_open = true;
loop {
let next = *deadline_rx.borrow_and_update();
tokio::select! {
biased;
_ = expire(next) => {
if let Some(current) = *deadline_rx.borrow_and_update()
&& current.at <= Instant::now()
{
return IoEnd::Policy(current.failure());
}
}
changed = deadline_rx.changed(), if deadlines_open => {
deadlines_open = changed.is_ok();
}
end = &mut pump => return end,
}
}
}
fn reject_queued(msg: OutboundMsg) {
match msg {
OutboundMsg::Send { reply, .. } => {
let _ = reply.send(Err(Error::DuplexClosed));
}
OutboundMsg::Interrupt { reply } => {
let _ = reply.send(Err(Error::DuplexClosed));
}
OutboundMsg::PermissionResponse { .. } => {}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_session_child(
mut child: Child,
mut group: crate::exec::GroupKillGuard,
kill_grace: Option<Duration>,
termination_signal: crate::TerminationSignal,
stdin: ChildStdin,
stdout: ChildStdout,
mut outbound_rx: mpsc::UnboundedReceiver<OutboundMsg>,
events_tx: broadcast::Sender<InboundEvent>,
permission_handler: Option<PermissionHandler>,
policy: DuplexPolicy,
mut stderr_failed: oneshot::Receiver<()>,
) -> Result<(ExitStatus, bool)> {
let (deadline_tx, mut deadline_rx) = watch::channel(None);
let mut res = Resident {
stdin,
frames: FrameReader::new(stdout, policy.max_frame_bytes),
turn_bytes: TurnBytes {
limit: policy.max_turn_bytes,
used: None,
},
pending: None,
turn_deadline: None,
pending_control: HashMap::new(),
next_control_id: 0,
deadline_tx,
policy,
session_span: tracing::Span::current(),
turns: 0,
turn_span: None,
turn_started: None,
last_total_cost: None,
};
let end = supervise(
pump(
&mut res,
&mut outbound_rx,
&events_tx,
permission_handler.as_ref(),
&mut stderr_failed,
),
&mut deadline_rx,
)
.await;
outbound_rx.close();
while let Ok(msg) = outbound_rx.try_recv() {
reject_queued(msg);
}
let mut failure = None;
let mut stream_err = None;
match end {
IoEnd::Policy(f) => failure = Some(f),
IoEnd::Read(error) => stream_err = Some(error),
IoEnd::Eof | IoEnd::Closed | IoEnd::StderrFailed => {}
}
let Resident {
stdin,
frames,
pending,
pending_control,
session_span,
turns,
..
} = res;
drop(stdin);
let mut stdout = frames.into_inner();
let mut unused_stderr = tokio::io::empty();
let mut status = None;
if failure.is_none() {
let graceful = crate::exec::drain_until(
tokio::time::timeout(SHUTDOWN_BUDGET, child.wait()),
&mut stdout,
&mut unused_stderr,
);
let waited = tokio::select! {
biased;
waited = graceful => Some(waited),
expired = expire(*deadline_rx.borrow()) => {
failure = Some(expired);
None
}
};
if let Some(waited) = waited {
let settlement = settle_graceful(
waited,
&mut child,
&mut group,
kill_grace,
termination_signal,
&mut stdout,
);
tokio::pin!(settlement);
status = Some(tokio::select! {
biased;
settled = &mut settlement => settled,
expired = expire(*deadline_rx.borrow()) => {
failure = Some(expired);
settlement.await
}
});
}
}
let mut process_settled = status.as_ref().is_some_and(Result::is_ok);
if let Some(failure) = failure
&& status.is_none()
{
warn!(?failure, "duplex policy tripped; stopping child");
match crate::exec::drain_until(
crate::exec::stop_and_reap(
&mut child,
&mut group,
kill_grace,
termination_signal,
None,
),
&mut stdout,
&mut unused_stderr,
)
.await
{
Ok(()) => process_settled = true,
Err(error) => warn!(%error, "failed to settle duplex child after policy failure"),
}
}
let settle_error = || match failure {
Some(failure) => failure.error(process_settled),
None => Error::DuplexClosed,
};
if let Some((reply, _)) = pending {
let _ = reply.send(Err(settle_error()));
}
for (_, (reply, _)) in pending_control {
let _ = reply.send(Err(settle_error()));
}
session_span.record("turns", turns);
match (failure, stream_err, status) {
(Some(failure), _, _) => Err(failure.error(process_settled)),
(None, Some(error), _) => Err(error),
(None, None, Some(status)) => status,
(None, None, None) => unreachable!("graceful shutdown always records a status"),
}
}
async fn settle_graceful(
waited: std::result::Result<std::io::Result<ExitStatus>, tokio::time::error::Elapsed>,
child: &mut Child,
group: &mut crate::exec::GroupKillGuard,
kill_grace: Option<Duration>,
termination_signal: crate::TerminationSignal,
stdout: &mut BufReader<ChildStdout>,
) -> Result<(ExitStatus, bool)> {
let mut unused_stderr = tokio::io::empty();
match waited {
Ok(Ok(status)) => {
group.disarm();
Ok((status, false))
}
Ok(Err(source)) => {
let error = Error::Io {
message: "failed to wait for duplex child".into(),
source,
working_dir: None,
};
match crate::exec::drain_until(
crate::exec::stop_and_reap(child, group, kill_grace, termination_signal, None),
stdout,
&mut unused_stderr,
)
.await
{
Ok(()) => Err(error),
Err(cleanup) => Err(cleanup),
}
}
Err(_) => {
warn!("duplex child did not exit within shutdown budget; stopping");
match crate::exec::drain_until(
crate::exec::stop_and_reap(child, group, kill_grace, termination_signal, None),
stdout,
&mut unused_stderr,
)
.await
{
Ok(()) => child
.wait()
.await
.map(|status| (status, true))
.map_err(|source| Error::Io {
message: "failed to wait for stopped duplex child".into(),
source,
working_dir: None,
}),
Err(error) => Err(error),
}
}
}
}
enum InboundAction {
None,
Permission(PermissionRequest),
ControlResponse {
request_id: String,
outcome: Result<()>,
},
EventLimit(usize),
}
fn event_limit(pending: &PendingTurn, max_events: Option<usize>) -> Option<usize> {
let (_, events) = pending.as_ref()?;
max_events.filter(|max| events.len() >= *max)
}
fn handle_inbound(
msg: Value,
pending: &mut PendingTurn,
events_tx: &broadcast::Sender<InboundEvent>,
max_events: Option<usize>,
) -> InboundAction {
let kind = msg.get("type").and_then(Value::as_str);
let retained = match kind {
Some("result") => false,
Some("control_response") => parse_control_response(&msg).is_none(),
_ => true,
};
if retained && let Some(max) = event_limit(pending, max_events) {
return InboundAction::EventLimit(max);
}
match kind {
Some("result") => {
if let Some((reply, events)) = pending.take() {
let _ = reply.send(Ok(TurnResult {
result: msg,
events,
}));
} else {
debug!("dropping orphan result event with no pending turn");
}
InboundAction::None
}
Some("control_request") => {
if msg
.get("request")
.and_then(|r| r.get("subtype"))
.and_then(Value::as_str)
== Some("can_use_tool")
&& let Some(req) = parse_permission_request(&msg)
{
if let Some((_, events)) = pending.as_mut() {
events.push(msg);
}
return InboundAction::Permission(req);
}
debug!("received unhandled control_request; treating as Other");
let _ = events_tx.send(InboundEvent::Other(msg.clone()));
if let Some((_, events)) = pending.as_mut() {
events.push(msg);
}
InboundAction::None
}
Some("control_response") => {
if let Some((request_id, outcome)) = parse_control_response(&msg) {
return InboundAction::ControlResponse {
request_id,
outcome,
};
}
debug!("received malformed control_response; treating as Other");
let _ = events_tx.send(InboundEvent::Other(msg.clone()));
if let Some((_, events)) = pending.as_mut() {
events.push(msg);
}
InboundAction::None
}
_ => {
let _ = events_tx.send(classify(&msg));
if let Some((_, events)) = pending.as_mut() {
events.push(msg);
} else {
debug!("dropping inbound event with no pending turn");
}
InboundAction::None
}
}
}
fn parse_permission_request(msg: &Value) -> Option<PermissionRequest> {
let request_id = msg.get("request_id").and_then(Value::as_str)?;
let request = msg.get("request")?;
let tool_name = request.get("tool_name").and_then(Value::as_str)?;
let input = request.get("input").cloned().unwrap_or(Value::Null);
Some(PermissionRequest {
request_id: request_id.to_string(),
tool_name: tool_name.to_string(),
input,
raw: request.clone(),
})
}
fn parse_control_response(msg: &Value) -> Option<(String, Result<()>)> {
let response = msg.get("response")?;
let request_id = response.get("request_id").and_then(Value::as_str)?;
let outcome = match response.get("subtype").and_then(Value::as_str) {
Some("success") => Ok(()),
Some("error") => {
let message = response
.get("error")
.and_then(Value::as_str)
.unwrap_or("unknown control_response error")
.to_string();
Err(Error::DuplexControlFailed { message })
}
_ => return None,
};
Some((request_id.to_string(), outcome))
}
async fn write_user(stdin: &mut ChildStdin, prompt: &str) -> Result<()> {
let user_msg = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": prompt,
},
"parent_tool_use_id": null,
});
write_line(stdin, &user_msg, "user message").await
}
async fn write_control_request(
stdin: &mut ChildStdin,
request_id: &str,
subtype: &str,
) -> Result<()> {
let envelope = serde_json::json!({
"type": "control_request",
"request_id": request_id,
"request": { "subtype": subtype },
});
write_line(stdin, &envelope, "control_request").await
}
async fn write_permission_response(
stdin: &mut ChildStdin,
request_id: &str,
decision: &PermissionDecision,
) -> Result<()> {
let inner = match decision {
PermissionDecision::Allow { updated_input } => {
let mut obj = serde_json::Map::new();
obj.insert("behavior".to_string(), Value::String("allow".to_string()));
if let Some(input) = updated_input {
obj.insert("updatedInput".to_string(), input.clone());
}
Value::Object(obj)
}
PermissionDecision::Deny { message } => serde_json::json!({
"behavior": "deny",
"message": message,
}),
PermissionDecision::Defer => {
return Ok(());
}
};
let envelope = serde_json::json!({
"type": "control_response",
"response": {
"request_id": request_id,
"subtype": "success",
"response": inner,
},
});
write_line(stdin, &envelope, "control_response").await
}
async fn write_line(stdin: &mut ChildStdin, value: &Value, what: &'static str) -> Result<()> {
let mut line = serde_json::to_string(value).map_err(|e| Error::Json {
message: format!("failed to serialize duplex {what}"),
source: e,
})?;
line.push('\n');
stdin
.write_all(line.as_bytes())
.await
.map_err(|e| Error::Io {
message: format!("failed to write {what} to duplex stdin"),
source: e,
working_dir: None,
})?;
stdin.flush().await.map_err(|e| Error::Io {
message: "failed to flush duplex stdin".to_string(),
source: e,
working_dir: None,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn handle(
msg: Value,
pending: &mut PendingTurn,
events_tx: &broadcast::Sender<InboundEvent>,
) -> InboundAction {
handle_inbound(msg, pending, events_tx, None)
}
#[test]
fn build_args_default_includes_required_flags() {
let args = DuplexOptions::default().build_args();
assert!(args.contains(&"--print".to_string()));
assert!(args.contains(&"--verbose".to_string()));
assert!(
args.windows(2)
.any(|w| w == ["--output-format", "stream-json"])
);
assert!(
args.windows(2)
.any(|w| w == ["--input-format", "stream-json"])
);
}
#[test]
fn build_args_includes_model() {
let args = DuplexOptions::default().model("haiku").build_args();
assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
}
#[test]
fn build_args_includes_system_prompts() {
let args = DuplexOptions::default()
.system_prompt("be concise")
.append_system_prompt("also polite")
.build_args();
assert!(
args.windows(2)
.any(|w| w == ["--system-prompt", "be concise"])
);
assert!(
args.windows(2)
.any(|w| w == ["--append-system-prompt", "also polite"])
);
}
#[test]
fn build_args_appends_raw_args_last() {
let args = DuplexOptions::default()
.arg("--add-dir")
.arg("/tmp/foo")
.build_args();
assert_eq!(&args[args.len() - 2..], &["--add-dir", "/tmp/foo"]);
}
fn preview_claude() -> Claude {
Claude::builder()
.binary("/usr/local/bin/claude")
.build()
.unwrap()
}
#[test]
fn spawn_command_args_prepends_global_args() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.arg("--debug")
.build()
.unwrap();
let args = DuplexOptions::default()
.model("haiku")
.spawn_command_args(&claude);
assert_eq!(args[0], "--debug");
assert_eq!(args[1], "--print");
assert!(args.windows(2).any(|w| w == ["--model", "haiku"]));
}
#[test]
fn to_command_string_is_binary_plus_spawn_args() {
let claude = Claude::builder()
.binary("/usr/local/bin/claude")
.arg("--debug")
.build()
.unwrap();
let opts = DuplexOptions::default().agent("reviewer");
let expected = format!(
"/usr/local/bin/claude {}",
opts.spawn_command_args(&claude).join(" ")
);
assert_eq!(opts.to_command_string(&claude), expected);
}
#[test]
fn to_command_string_includes_persona_flags() {
let command_str = DuplexOptions::default()
.agent("reviewer")
.allowed_tool("Read")
.allowed_tool("Bash(git:*)")
.setting_sources("project")
.to_command_string(&preview_claude());
assert!(command_str.starts_with("/usr/local/bin/claude"));
assert!(command_str.contains("--agent reviewer"));
assert!(command_str.contains("--allowed-tools 'Read,Bash(git:*)'"));
assert!(command_str.contains("--setting-sources project"));
}
#[test]
fn to_command_string_quotes_args_with_spaces() {
let command_str = DuplexOptions::default()
.system_prompt("be concise")
.to_command_string(&preview_claude());
assert!(command_str.contains("--system-prompt 'be concise'"));
}
#[test]
fn to_command_string_does_not_consume_options() {
let claude = preview_claude();
let opts = DuplexOptions::default().model("haiku");
let first = opts.to_command_string(&claude);
let second = opts.to_command_string(&claude);
assert_eq!(first, second);
}
#[test]
fn build_args_includes_resume_when_set() {
let args = DuplexOptions::default().resume("abc-123").build_args();
assert!(args.windows(2).any(|w| w == ["--resume", "abc-123"]));
}
#[test]
fn build_args_omits_resume_by_default() {
let args = DuplexOptions::default().build_args();
assert!(
!args.iter().any(|a| a == "--resume"),
"--resume should not appear without an explicit resume(...) call; got {args:?}"
);
}
#[test]
fn build_args_includes_continue_when_set() {
let args = DuplexOptions::default().continue_session().build_args();
assert!(args.iter().any(|a| a == "--continue"));
}
#[test]
fn build_args_omits_continue_by_default() {
let args = DuplexOptions::default().build_args();
assert!(!args.iter().any(|a| a == "--continue"));
}
#[test]
fn build_args_includes_worktree_flag_without_name() {
let args = DuplexOptions::default().worktree(None::<&str>).build_args();
assert!(args.iter().any(|a| a == "--worktree"));
let pos = args.iter().position(|a| a == "--worktree").unwrap();
assert!(
args.get(pos + 1).is_none_or(|a| a.starts_with("--")),
"--worktree without a name should not be followed by a positional; got {args:?}"
);
}
#[test]
fn build_args_includes_worktree_flag_with_name() {
let args = DuplexOptions::default()
.worktree(Some("agent-xyz"))
.build_args();
let pos = args.iter().position(|a| a == "--worktree").unwrap();
assert_eq!(args.get(pos + 1).map(String::as_str), Some("agent-xyz"));
}
#[test]
fn build_args_omits_worktree_by_default() {
let args = DuplexOptions::default().build_args();
assert!(
!args.iter().any(|a| a == "--worktree"),
"--worktree should not appear without an explicit worktree(...) call; got {args:?}"
);
}
#[test]
fn worktree_lands_before_additional_args() {
let args = DuplexOptions::default()
.worktree(Some("foo"))
.arg("--")
.arg("trailing")
.build_args();
let wt_pos = args.iter().position(|a| a == "--worktree").unwrap();
let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
assert!(
wt_pos < dash_dash_pos,
"--worktree must precede `--` separator; got {args:?}"
);
}
#[test]
fn build_args_includes_agent_when_set() {
let args = DuplexOptions::default().agent("rust-qa").build_args();
assert!(
args.windows(2).any(|w| w == ["--agent", "rust-qa"]),
"missing --agent rust-qa in {args:?}"
);
}
#[test]
fn build_args_omits_agent_by_default() {
let args = DuplexOptions::default().build_args();
assert!(
!args.iter().any(|a| a == "--agent"),
"--agent should not appear without an explicit agent(...) call; got {args:?}"
);
}
#[test]
fn build_args_includes_agents_json_when_set() {
let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
let args = DuplexOptions::default().agents_json(json).build_args();
let pos = args.iter().position(|a| a == "--agents").unwrap();
assert_eq!(args.get(pos + 1).map(String::as_str), Some(json));
}
#[test]
fn build_args_omits_agents_json_by_default() {
let args = DuplexOptions::default().build_args();
assert!(!args.iter().any(|a| a == "--agents"));
}
#[test]
fn agent_and_agents_json_compose() {
let json = r#"{"reviewer":{"description":"r","prompt":"p"}}"#;
let args = DuplexOptions::default()
.agents_json(json)
.agent("reviewer")
.build_args();
assert!(args.iter().any(|a| a == "--agents"));
assert!(args.iter().any(|a| a == "--agent"));
}
#[test]
fn agent_lands_before_additional_args() {
let args = DuplexOptions::default()
.agent("rust-qa")
.arg("--")
.arg("trailing")
.build_args();
let agent_pos = args.iter().position(|a| a == "--agent").unwrap();
let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
assert!(
agent_pos < dash_dash_pos,
"--agent must precede `--` separator; got {args:?}"
);
}
#[test]
fn agents_json_lands_before_additional_args() {
let args = DuplexOptions::default()
.agents_json("{}")
.arg("--")
.arg("trailing")
.build_args();
let agents_pos = args.iter().position(|a| a == "--agents").unwrap();
let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
assert!(
agents_pos < dash_dash_pos,
"--agents must precede `--` separator; got {args:?}"
);
}
#[test]
fn build_args_includes_session_id() {
let args = DuplexOptions::default().session_id("sid-9").build_args();
assert!(args.windows(2).any(|w| w == ["--session-id", "sid-9"]));
}
#[test]
fn build_args_includes_setting_sources() {
let args = DuplexOptions::default()
.setting_sources("user,project")
.build_args();
assert!(
args.windows(2)
.any(|w| w == ["--setting-sources", "user,project"]),
"got {args:?}"
);
}
#[test]
fn build_args_omits_setting_sources_by_default() {
let args = DuplexOptions::default().build_args();
assert!(!args.iter().any(|a| a == "--setting-sources"));
}
#[test]
fn build_args_hermetic_emits_full_seal() {
let args = DuplexOptions::default().hermetic().build_args();
assert!(
args.windows(2)
.any(|w| w[0] == "--setting-sources" && w[1].is_empty()),
"got {args:?}"
);
assert!(args.iter().any(|a| a == "--strict-mcp-config"));
assert!(
args.iter()
.any(|a| a == "--exclude-dynamic-system-prompt-sections")
);
assert!(!args.iter().any(|a| a == "--bare"));
}
#[test]
fn build_args_hermetic_scoped_project_keeps_user() {
let args = DuplexOptions::default()
.hermetic_scoped(HermeticScope::Project)
.build_args();
assert!(args.windows(2).any(|w| w == ["--setting-sources", "user"]));
assert!(args.iter().any(|a| a == "--strict-mcp-config"));
}
#[test]
fn build_args_includes_json_schema() {
let schema = r#"{"type":"object"}"#;
let args = DuplexOptions::default().json_schema(schema).build_args();
assert!(args.windows(2).any(|w| w == ["--json-schema", schema]));
}
#[test]
fn build_args_joins_allowed_tools_comma_separated() {
let args = DuplexOptions::default()
.allowed_tools(["Read", "Bash(git log:*)"])
.allowed_tool("Write")
.build_args();
assert!(
args.windows(2)
.any(|w| w == ["--allowed-tools", "Read,Bash(git log:*),Write"]),
"missing joined --allowed-tools in {args:?}"
);
}
#[test]
fn build_args_joins_disallowed_tools_comma_separated() {
let args = DuplexOptions::default()
.disallowed_tools(["WebSearch"])
.disallowed_tool("WebFetch")
.build_args();
assert!(
args.windows(2)
.any(|w| w == ["--disallowed-tools", "WebSearch,WebFetch"]),
"missing joined --disallowed-tools in {args:?}"
);
}
#[test]
fn build_args_includes_caps() {
let args = DuplexOptions::default()
.max_turns(4)
.max_budget_usd(0.25)
.build_args();
assert!(args.windows(2).any(|w| w == ["--max-turns", "4"]));
assert!(args.windows(2).any(|w| w == ["--max-budget-usd", "0.25"]));
}
#[test]
fn build_args_includes_fallback_model_and_effort() {
let args = DuplexOptions::default()
.fallback_model("haiku")
.effort(Effort::Low)
.build_args();
assert!(args.windows(2).any(|w| w == ["--fallback-model", "haiku"]));
assert!(args.windows(2).any(|w| w == ["--effort", "low"]));
}
#[test]
fn build_args_repeats_add_dir_and_mcp_config() {
let args = DuplexOptions::default()
.add_dir("/a")
.add_dir("/b")
.mcp_config("x.json")
.strict_mcp_config()
.build_args();
assert!(args.windows(2).any(|w| w == ["--add-dir", "/a"]));
assert!(args.windows(2).any(|w| w == ["--add-dir", "/b"]));
assert!(args.windows(2).any(|w| w == ["--mcp-config", "x.json"]));
assert!(args.iter().any(|a| a == "--strict-mcp-config"));
}
#[test]
fn build_args_includes_no_session_persistence() {
let args = DuplexOptions::default()
.no_session_persistence()
.build_args();
assert!(args.iter().any(|a| a == "--no-session-persistence"));
}
#[test]
fn build_args_joins_tools_comma_separated() {
let args = DuplexOptions::default()
.tools(["Bash", "Read", "Edit"])
.build_args();
assert!(
args.windows(2).any(|w| w == ["--tools", "Bash,Read,Edit"]),
"missing joined --tools in {args:?}"
);
}
#[test]
fn build_args_repeats_file_per_spec() {
let args = DuplexOptions::default()
.file("file_a:doc.txt")
.file("file_b:notes.md")
.build_args();
assert_eq!(args.iter().filter(|a| *a == "--file").count(), 2);
assert!(args.iter().any(|a| a == "file_a:doc.txt"));
assert!(args.iter().any(|a| a == "file_b:notes.md"));
}
#[test]
fn build_args_includes_settings() {
let args = DuplexOptions::default()
.settings("/tmp/settings.json")
.build_args();
assert!(
args.windows(2)
.any(|w| w == ["--settings", "/tmp/settings.json"])
);
}
#[test]
fn build_args_includes_fork_session() {
let args = DuplexOptions::default().fork_session().build_args();
assert!(args.iter().any(|a| a == "--fork-session"));
}
#[test]
fn build_args_includes_debug_filter_and_file() {
let args = DuplexOptions::default()
.debug_filter("api,hooks")
.debug_file("/tmp/debug.log")
.build_args();
assert!(args.windows(2).any(|w| w == ["--debug", "api,hooks"]));
assert!(
args.windows(2)
.any(|w| w == ["--debug-file", "/tmp/debug.log"])
);
}
#[test]
fn build_args_includes_betas() {
let args = DuplexOptions::default().betas("feature-x").build_args();
assert!(args.windows(2).any(|w| w == ["--betas", "feature-x"]));
}
#[test]
fn build_args_repeats_plugin_dir_and_url() {
let args = DuplexOptions::default()
.plugin_dir("/plugins/a")
.plugin_dir("/plugins/b")
.plugin_url("https://example.com/p.zip")
.build_args();
assert_eq!(args.iter().filter(|a| *a == "--plugin-dir").count(), 2);
assert!(
args.windows(2)
.any(|w| w == ["--plugin-url", "https://example.com/p.zip"])
);
}
#[test]
fn build_args_includes_bare_family_bool_flags() {
let args = DuplexOptions::default()
.tmux()
.bare()
.safe_mode()
.disable_slash_commands()
.include_hook_events()
.exclude_dynamic_system_prompt_sections()
.build_args();
for flag in [
"--tmux",
"--bare",
"--safe-mode",
"--disable-slash-commands",
"--include-hook-events",
"--exclude-dynamic-system-prompt-sections",
] {
assert!(args.iter().any(|a| a == flag), "missing {flag} in {args:?}");
}
}
#[test]
fn build_args_includes_name() {
let args = DuplexOptions::default().name("my session").build_args();
assert!(args.windows(2).any(|w| w == ["--name", "my session"]));
}
#[test]
fn build_args_omits_promoted_parity_flags_by_default() {
let args = DuplexOptions::default().build_args();
for flag in [
"--tools",
"--file",
"--settings",
"--fork-session",
"--debug",
"--debug-file",
"--betas",
"--plugin-dir",
"--plugin-url",
"--tmux",
"--bare",
"--safe-mode",
"--disable-slash-commands",
"--include-hook-events",
"--exclude-dynamic-system-prompt-sections",
"--name",
] {
assert!(
!args.iter().any(|a| a == flag),
"{flag} should be absent by default; got {args:?}"
);
}
}
#[test]
fn parity_flags_land_before_additional_args() {
let args = DuplexOptions::default()
.max_turns(2)
.json_schema("{}")
.arg("--")
.arg("trailing")
.build_args();
let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
for flag in ["--max-turns", "--json-schema"] {
let pos = args.iter().position(|a| a == flag).unwrap();
assert!(
pos < dash_dash_pos,
"{flag} must precede `--` separator; got {args:?}"
);
}
}
#[test]
fn build_args_omits_parity_flags_by_default() {
let args = DuplexOptions::default().build_args();
for flag in [
"--session-id",
"--json-schema",
"--allowed-tools",
"--disallowed-tools",
"--max-turns",
"--max-budget-usd",
"--fallback-model",
"--effort",
"--add-dir",
"--mcp-config",
"--strict-mcp-config",
"--no-session-persistence",
] {
assert!(
!args.iter().any(|a| a == flag),
"{flag} should not appear by default; got {args:?}"
);
}
}
#[test]
fn resume_lands_before_additional_args() {
let args = DuplexOptions::default()
.resume("xyz")
.arg("--")
.arg("trailing")
.build_args();
let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
let dash_dash_pos = args.iter().position(|a| a == "--").unwrap();
assert!(
resume_pos < dash_dash_pos,
"--resume must precede `--` separator; got {args:?}"
);
}
#[test]
fn turn_result_accessors_pull_from_result() {
let r = TurnResult {
result: json!({
"type": "result",
"result": "hello",
"session_id": "sess-123",
"total_cost_usd": 0.0042,
"duration_ms": 1234_u64,
}),
events: vec![],
};
assert_eq!(r.result_text(), Some("hello"));
assert_eq!(r.session_id(), Some("sess-123"));
assert_eq!(r.total_cost_usd(), Some(0.0042));
assert_eq!(r.duration_ms(), Some(1234));
}
#[test]
fn turn_result_total_cost_falls_back_to_legacy_field() {
let r = TurnResult {
result: json!({ "cost_usd": 0.5 }),
events: vec![],
};
assert_eq!(r.total_cost_usd(), Some(0.5));
}
#[test]
fn turn_result_accessors_return_none_when_missing() {
let r = TurnResult {
result: json!({}),
events: vec![],
};
assert_eq!(r.result_text(), None);
assert_eq!(r.session_id(), None);
assert_eq!(r.total_cost_usd(), None);
assert_eq!(r.duration_ms(), None);
assert_eq!(r.subtype(), None);
assert!(!r.is_error());
}
#[test]
fn turn_result_subtype_and_is_error_for_interrupted_turn() {
let r = TurnResult {
result: json!({
"type": "result",
"subtype": "error_during_execution",
"is_error": true,
"total_cost_usd": 0.0004,
}),
events: vec![],
};
assert_eq!(r.subtype(), Some("error_during_execution"));
assert!(r.is_error());
assert_eq!(r.result_text(), None);
}
#[test]
fn turn_cost_delta_tracks_previous_total() {
let mut prev = None;
assert_eq!(turn_cost_delta(&mut prev, Some(0.25)), Some(0.25));
assert_eq!(turn_cost_delta(&mut prev, Some(0.75)), Some(0.5));
assert_eq!(turn_cost_delta(&mut prev, None), None);
assert_eq!(prev, Some(0.75));
assert_eq!(turn_cost_delta(&mut prev, Some(0.5)), Some(0.0));
assert_eq!(turn_cost_delta(&mut prev, Some(1.0)), Some(0.5));
}
#[test]
fn handle_inbound_appends_non_result_to_pending_events() {
let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, _events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
handle(
json!({ "type": "assistant", "message": {} }),
&mut pending,
&events_tx,
);
let (_, events) = pending.as_ref().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].get("type").and_then(Value::as_str),
Some("assistant")
);
}
#[test]
fn handle_inbound_resolves_pending_on_result() {
let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, _events_rx) = broadcast::channel(16);
let mut pending = Some((tx, vec![json!({ "type": "assistant" })]));
handle(
json!({ "type": "result", "result": "ok" }),
&mut pending,
&events_tx,
);
assert!(pending.is_none());
let received = rx.blocking_recv().unwrap().unwrap();
assert_eq!(received.result_text(), Some("ok"));
assert_eq!(received.events.len(), 1);
}
#[test]
fn handle_inbound_drops_orphans_without_pending_turn() {
let (events_tx, _events_rx) = broadcast::channel(16);
let mut pending: Option<(oneshot::Sender<Result<TurnResult>>, Vec<Value>)> = None;
handle(json!({ "type": "assistant" }), &mut pending, &events_tx);
handle(
json!({ "type": "result", "result": "ok" }),
&mut pending,
&events_tx,
);
assert!(pending.is_none());
}
#[test]
fn handle_inbound_broadcasts_classified_event() {
let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, mut events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
handle(
json!({ "type": "assistant", "message": { "role": "assistant" } }),
&mut pending,
&events_tx,
);
let event = events_rx.try_recv().expect("classified event broadcast");
assert!(matches!(event, InboundEvent::Assistant(_)));
}
#[test]
fn handle_inbound_does_not_broadcast_result() {
let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, mut events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
handle(
json!({ "type": "result", "result": "ok" }),
&mut pending,
&events_tx,
);
assert!(events_rx.try_recv().is_err());
}
#[test]
fn classify_system_init_pulls_session_id() {
let v = json!({
"type": "system",
"subtype": "init",
"session_id": "sess-abc",
});
match classify(&v) {
InboundEvent::SystemInit { session_id } => assert_eq!(session_id, "sess-abc"),
other => panic!("expected SystemInit, got {other:?}"),
}
}
#[test]
fn classify_system_without_init_subtype_is_other() {
let v = json!({ "type": "system", "subtype": "compaction" });
assert!(matches!(classify(&v), InboundEvent::Other(_)));
}
#[test]
fn classify_system_init_without_session_id_is_other() {
let v = json!({ "type": "system", "subtype": "init" });
assert!(matches!(classify(&v), InboundEvent::Other(_)));
}
#[test]
fn classify_assistant_stream_event_user() {
assert!(matches!(
classify(&json!({ "type": "assistant" })),
InboundEvent::Assistant(_)
));
assert!(matches!(
classify(&json!({ "type": "stream_event" })),
InboundEvent::StreamEvent(_)
));
assert!(matches!(
classify(&json!({ "type": "user" })),
InboundEvent::User(_)
));
}
#[test]
fn classify_unknown_type_is_other() {
assert!(matches!(
classify(&json!({ "type": "control_request" })),
InboundEvent::Other(_)
));
assert!(matches!(
classify(&json!({ "type": "future_thing" })),
InboundEvent::Other(_)
));
assert!(matches!(classify(&json!({})), InboundEvent::Other(_)));
}
#[test]
fn build_args_does_not_emit_subscriber_capacity_flag() {
let args = DuplexOptions::default()
.subscriber_capacity(64)
.build_args();
assert!(!args.iter().any(|a| a.contains("subscriber")));
assert!(!args.iter().any(|a| a.contains("capacity")));
}
#[test]
fn build_args_includes_permission_prompt_tool_when_handler_set() {
let handler = PermissionHandler::new(|_req| async move {
PermissionDecision::Allow {
updated_input: None,
}
});
let args = DuplexOptions::default().on_permission(handler).build_args();
assert!(
args.windows(2)
.any(|w| w == ["--permission-prompt-tool", "stdio"])
);
}
#[test]
fn build_args_omits_permission_prompt_tool_without_handler() {
let args = DuplexOptions::default().build_args();
assert!(!args.iter().any(|a| a == "--permission-prompt-tool"));
}
#[test]
fn build_args_emits_permission_mode_flag() {
let args = DuplexOptions::default()
.permission_mode(PermissionMode::AcceptEdits)
.build_args();
assert!(
args.windows(2)
.any(|w| w == ["--permission-mode", "acceptEdits"]),
"missing --permission-mode acceptEdits in {args:?}"
);
}
#[test]
fn build_args_emits_plan_mode() {
let args = DuplexOptions::default()
.permission_mode(PermissionMode::Plan)
.build_args();
assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
}
#[test]
fn build_args_omits_permission_mode_by_default() {
let args = DuplexOptions::default().build_args();
assert!(!args.iter().any(|a| a == "--permission-mode"));
}
#[test]
fn build_args_emits_dangerously_skip_permissions_flag() {
let args = DuplexOptions::default()
.dangerously_skip_permissions()
.build_args();
assert!(args.iter().any(|a| a == "--dangerously-skip-permissions"));
}
#[test]
fn build_args_omits_dangerously_skip_by_default() {
let args = DuplexOptions::default().build_args();
assert!(!args.iter().any(|a| a == "--dangerously-skip-permissions"));
}
#[test]
fn parse_permission_request_extracts_fields() {
let msg = json!({
"type": "control_request",
"request_id": "req-1",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
"input": { "command": "ls" }
}
});
let req = parse_permission_request(&msg).expect("permission request");
assert_eq!(req.request_id, "req-1");
assert_eq!(req.tool_name, "Bash");
assert_eq!(req.input, json!({ "command": "ls" }));
assert_eq!(
req.raw.get("subtype").and_then(Value::as_str),
Some("can_use_tool")
);
}
#[test]
fn parse_permission_request_returns_none_when_missing_request_id() {
let msg = json!({
"type": "control_request",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
}
});
assert!(parse_permission_request(&msg).is_none());
}
#[test]
fn parse_permission_request_returns_none_when_missing_tool_name() {
let msg = json!({
"type": "control_request",
"request_id": "req-1",
"request": { "subtype": "can_use_tool" }
});
assert!(parse_permission_request(&msg).is_none());
}
#[test]
fn parse_permission_request_handles_missing_input() {
let msg = json!({
"type": "control_request",
"request_id": "req-1",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
}
});
let req = parse_permission_request(&msg).expect("request");
assert_eq!(req.input, Value::Null);
}
#[test]
fn handle_inbound_returns_permission_for_can_use_tool() {
let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, _events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
let action = handle(
json!({
"type": "control_request",
"request_id": "req-1",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
"input": { "command": "ls" }
}
}),
&mut pending,
&events_tx,
);
match action {
InboundAction::Permission(req) => {
assert_eq!(req.request_id, "req-1");
assert_eq!(req.tool_name, "Bash");
}
InboundAction::None
| InboundAction::ControlResponse { .. }
| InboundAction::EventLimit(_) => {
panic!("expected Permission action");
}
}
let (_, events) = pending.as_ref().unwrap();
assert_eq!(events.len(), 1);
}
#[test]
fn handle_inbound_treats_unknown_control_request_as_other() {
let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, mut events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
let action = handle(
json!({
"type": "control_request",
"request_id": "req-2",
"request": { "subtype": "future_subtype" }
}),
&mut pending,
&events_tx,
);
assert!(matches!(action, InboundAction::None));
let event = events_rx.try_recv().expect("broadcast");
assert!(matches!(event, InboundEvent::Other(_)));
}
#[tokio::test]
async fn permission_handler_invokes_closure_async() {
let handler = PermissionHandler::new(|req| async move {
if req.tool_name == "Bash" {
PermissionDecision::Deny {
message: "no bash".into(),
}
} else {
PermissionDecision::Allow {
updated_input: None,
}
}
});
let req = PermissionRequest {
request_id: "r1".into(),
tool_name: "Bash".into(),
input: Value::Null,
raw: Value::Null,
};
match handler.invoke(req).await {
PermissionDecision::Deny { message } => assert_eq!(message, "no bash"),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn parse_control_response_extracts_success() {
let msg = json!({
"type": "control_response",
"response": {
"request_id": "interrupt-1",
"subtype": "success",
"response": {}
}
});
let (id, outcome) = parse_control_response(&msg).expect("parsed");
assert_eq!(id, "interrupt-1");
assert!(outcome.is_ok());
}
#[test]
fn parse_control_response_extracts_error_with_message() {
let msg = json!({
"type": "control_response",
"response": {
"request_id": "interrupt-2",
"subtype": "error",
"error": "no turn in flight"
}
});
let (id, outcome) = parse_control_response(&msg).expect("parsed");
assert_eq!(id, "interrupt-2");
match outcome {
Err(Error::DuplexControlFailed { message }) => {
assert_eq!(message, "no turn in flight");
}
other => panic!("expected DuplexControlFailed, got {other:?}"),
}
}
#[test]
fn parse_control_response_returns_none_on_missing_request_id() {
let msg = json!({
"type": "control_response",
"response": { "subtype": "success" }
});
assert!(parse_control_response(&msg).is_none());
}
#[test]
fn parse_control_response_returns_none_on_unknown_subtype() {
let msg = json!({
"type": "control_response",
"response": { "request_id": "x", "subtype": "future_subtype" }
});
assert!(parse_control_response(&msg).is_none());
}
#[test]
fn handle_inbound_returns_control_response_action() {
let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, _events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
let action = handle(
json!({
"type": "control_response",
"response": {
"request_id": "interrupt-1",
"subtype": "success",
"response": {}
}
}),
&mut pending,
&events_tx,
);
match action {
InboundAction::ControlResponse {
request_id,
outcome,
} => {
assert_eq!(request_id, "interrupt-1");
assert!(outcome.is_ok());
}
InboundAction::None | InboundAction::Permission(_) | InboundAction::EventLimit(_) => {
panic!("expected ControlResponse action");
}
}
}
#[test]
fn handle_inbound_treats_malformed_control_response_as_other() {
let (tx, _reply_rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, mut events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
let action = handle(
json!({
"type": "control_response",
"response": { "subtype": "success" }
}),
&mut pending,
&events_tx,
);
assert!(matches!(action, InboundAction::None));
let event = events_rx.try_recv().expect("broadcast");
assert!(matches!(event, InboundEvent::Other(_)));
}
#[tokio::test]
async fn permission_handler_clones_arc() {
let handler = PermissionHandler::new(|_req| async move {
PermissionDecision::Allow {
updated_input: None,
}
});
let cloned = handler.clone();
let req = PermissionRequest {
request_id: "r1".into(),
tool_name: "Read".into(),
input: Value::Null,
raw: Value::Null,
};
let _ = handler.invoke(req.clone()).await;
let _ = cloned.invoke(req).await;
}
fn fake_session(
initial: SessionExitStatus,
) -> (
DuplexSession,
watch::Sender<SessionExitStatus>,
oneshot::Sender<()>,
) {
let (outbound_tx, outbound_rx) = mpsc::unbounded_channel::<OutboundMsg>();
let (events_tx, _events_rx) = broadcast::channel::<InboundEvent>(16);
let (exit_tx, exit_rx) = watch::channel(initial);
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let join = tokio::spawn(async move {
let _outbound_rx = outbound_rx;
let _ = stop_rx.await;
Ok::<(), Error>(())
});
let session = DuplexSession {
outbound_tx,
events_tx,
exit_rx,
join,
};
(session, exit_tx, stop_tx)
}
#[tokio::test]
async fn is_alive_true_while_running() {
let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
assert!(session.is_alive());
}
#[tokio::test]
async fn is_alive_false_after_completed() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
exit_tx.send(SessionExitStatus::Completed).unwrap();
assert!(!session.is_alive());
}
#[tokio::test]
async fn is_alive_false_after_failed() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
exit_tx
.send(SessionExitStatus::Failed("boom".into()))
.unwrap();
assert!(!session.is_alive());
}
#[tokio::test]
async fn exit_status_reports_running_initially() {
let (session, _exit_tx, _stop) = fake_session(SessionExitStatus::Running);
assert!(matches!(session.exit_status(), SessionExitStatus::Running));
}
#[tokio::test]
async fn exit_status_reflects_completed() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
exit_tx.send(SessionExitStatus::Completed).unwrap();
assert!(matches!(
session.exit_status(),
SessionExitStatus::Completed
));
}
#[tokio::test]
async fn exit_status_reflects_failed_with_message() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
exit_tx
.send(SessionExitStatus::Failed("oh no".into()))
.unwrap();
match session.exit_status() {
SessionExitStatus::Failed(msg) => assert_eq!(msg, "oh no"),
other => panic!("expected Failed, got {other:?}"),
}
}
#[tokio::test]
async fn wait_for_exit_returns_immediately_when_already_terminal() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
exit_tx.send(SessionExitStatus::Completed).unwrap();
let status = tokio::time::timeout(Duration::from_secs(1), session.wait_for_exit())
.await
.expect("wait_for_exit should not block when already terminal");
assert!(matches!(status, SessionExitStatus::Completed));
}
#[tokio::test]
async fn wait_for_exit_blocks_until_state_transitions() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
let waiter = async { session.wait_for_exit().await };
let driver = async {
tokio::time::sleep(Duration::from_millis(20)).await;
exit_tx.send(SessionExitStatus::Completed).unwrap();
};
let (status, ()) = tokio::join!(waiter, driver);
assert!(matches!(status, SessionExitStatus::Completed));
}
#[tokio::test]
async fn wait_for_exit_supports_multiple_observers() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
let waiter1 = async { session.wait_for_exit().await };
let waiter2 = async { session.wait_for_exit().await };
let driver = async {
tokio::time::sleep(Duration::from_millis(20)).await;
exit_tx
.send(SessionExitStatus::Failed("crash".into()))
.unwrap();
};
let (s1, s2, ()) = tokio::join!(waiter1, waiter2, driver);
match s1 {
SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
other => panic!("waiter1 expected Failed, got {other:?}"),
}
match s2 {
SessionExitStatus::Failed(msg) => assert_eq!(msg, "crash"),
other => panic!("waiter2 expected Failed, got {other:?}"),
}
}
#[tokio::test]
async fn wait_for_exit_returns_last_value_when_sender_dropped() {
let (session, exit_tx, _stop) = fake_session(SessionExitStatus::Running);
let waiter = async { session.wait_for_exit().await };
let driver = async {
tokio::time::sleep(Duration::from_millis(20)).await;
drop(exit_tx);
};
let (status, ()) = tokio::time::timeout(Duration::from_secs(1), async {
tokio::join!(waiter, driver)
})
.await
.expect("wait_for_exit must not hang when sender is dropped");
assert!(matches!(status, SessionExitStatus::Running));
}
#[tokio::test]
async fn stderr_reader_retries_interruptions_and_retains_bytes_on_failure() {
struct Reader(u8);
impl tokio::io::AsyncRead for Reader {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
self.0 += 1;
std::task::Poll::Ready(match self.0 {
1 => Err(std::io::ErrorKind::Interrupted.into()),
2 => {
buf.put_slice(b"retained");
Ok(())
}
_ => Err(std::io::Error::other("stderr read fault")),
})
}
}
let mut tail = VecDeque::new();
let error = read_stderr_tail(&mut Reader(0), &mut tail)
.await
.unwrap_err();
assert_eq!(error.to_string(), "stderr read fault");
assert_eq!(tail.into_iter().collect::<Vec<_>>(), b"retained");
}
#[cfg(unix)]
#[test]
fn stderr_read_failure_keeps_native_exit_evidence_and_diagnostics() {
for code in [0, 7] {
let status = std::process::Command::new("/bin/sh")
.args(["-c", &format!("exit {code}")])
.status()
.unwrap();
let result = resident_outcome(
Ok((status, false)),
Some(Err(std::io::Error::other("stderr read fault"))),
"retained diagnostics".into(),
);
match result {
Err(Error::DuplexProcessFailed {
status,
stderr,
stderr_read_error,
..
}) => {
assert_eq!(status.code(), Some(7));
assert_eq!(stderr, "retained diagnostics");
assert_eq!(stderr_read_error.unwrap().to_string(), "stderr read fault");
}
Err(Error::Io {
message, source, ..
}) if code == 0 => {
assert!(message.contains("retained diagnostics"));
assert_eq!(source.to_string(), "stderr read fault");
}
other => panic!("expected retained exit/read evidence, got {other:?}"),
}
}
}
#[test]
fn compatible_headless_flags_are_explicit_and_do_not_activate_bypass() {
let defaults = DuplexOptions::default().build_args();
assert!(
!defaults.iter().any(|arg| arg == "--forward-subagent-text"
|| arg == "--allow-dangerously-skip-permissions")
);
let args = DuplexOptions::default()
.forward_subagent_text()
.allow_dangerously_skip_permissions()
.build_args();
assert!(args.iter().any(|arg| arg == "--forward-subagent-text"));
assert!(
args.iter()
.any(|arg| arg == "--allow-dangerously-skip-permissions")
);
assert!(
!args
.iter()
.any(|arg| arg == "--dangerously-skip-permissions" || arg == "bypassPermissions")
);
}
#[test]
fn partial_messages_are_opt_in() {
let defaults = DuplexOptions::default().build_args();
assert!(!defaults.iter().any(|a| a == "--include-partial-messages"));
let args = DuplexOptions::default()
.include_partial_messages()
.arg("--")
.build_args();
let flag = args
.iter()
.position(|a| a == "--include-partial-messages")
.expect("opt-in emits the flag");
let dash_dash = args.iter().position(|a| a == "--").unwrap();
assert!(flag < dash_dash, "{args:?}");
}
#[test]
fn policies_are_unlimited_by_default_and_emit_no_flags() {
let defaults = DuplexOptions::default();
assert!(defaults.policy.max_frame_bytes.is_none());
assert!(defaults.policy.max_turn_bytes.is_none());
assert!(defaults.policy.max_turn_events.is_none());
assert!(defaults.policy.turn_timeout.is_none());
assert!(defaults.policy.control_timeout.is_none());
let configured = DuplexOptions::default()
.max_frame_bytes(1)
.max_turn_bytes(2)
.max_turn_events(3)
.turn_timeout(Duration::from_secs(4))
.control_timeout(Duration::from_secs(5));
assert_eq!(configured.build_args(), defaults.build_args());
}
#[test]
fn oversized_deadline_is_treated_as_unbounded() {
assert!(OpDeadline::start(DuplexOperation::Turn, Some(Duration::MAX)).is_none());
assert!(OpDeadline::start(DuplexOperation::Turn, None).is_none());
}
async fn frames_of(
input: &[u8],
max_frame: Option<usize>,
turn: &mut TurnBytes,
) -> (Vec<Vec<u8>>, Option<PolicyFailure>) {
let mut reader = FrameReader::new(input, max_frame);
let mut frames = Vec::new();
loop {
match reader.next_frame(turn).await {
Ok(FrameRead::Frame(frame)) => frames.push(frame),
Ok(FrameRead::Eof) => return (frames, None),
Err(FrameError::Limit(failure)) => return (frames, Some(failure)),
Err(FrameError::Io(error)) => panic!("unexpected io error: {error}"),
}
}
}
#[tokio::test]
async fn frame_reader_strips_terminators_and_keeps_a_final_partial_line() {
let mut turn = TurnBytes::default();
let (frames, failure) = frames_of(b"a\r\n\nb\nc", None, &mut turn).await;
assert!(failure.is_none());
assert_eq!(
frames,
vec![b"a".to_vec(), Vec::new(), b"b".to_vec(), b"c".to_vec()]
);
}
#[tokio::test]
async fn frame_limit_trips_before_an_unterminated_line_is_extended() {
let input = vec![b'x'; 1 << 20];
let mut reader = FrameReader::new(&input[..], Some(1024));
let mut turn = TurnBytes::default();
match reader.next_frame(&mut turn).await {
Err(FrameError::Limit(PolicyFailure::Limit { limit, max })) => {
assert_eq!(limit, DuplexLimit::FrameBytes);
assert_eq!(max, 1024);
}
_ => panic!("expected a frame limit failure"),
}
assert!(reader.partial.len() <= 1024, "{}", reader.partial.len());
assert!(
reader.partial.capacity() <= 2048,
"{}",
reader.partial.capacity()
);
}
#[tokio::test]
async fn frame_limit_counts_the_newline() {
let mut turn = TurnBytes::default();
let (frames, failure) = frames_of(b"abc\n", Some(4), &mut turn).await;
assert_eq!(frames, vec![b"abc".to_vec()]);
assert!(failure.is_none());
let (_, failure) = frames_of(b"abcd\n", Some(4), &mut turn).await;
assert!(matches!(
failure,
Some(PolicyFailure::Limit {
limit: DuplexLimit::FrameBytes,
..
})
));
}
#[tokio::test]
async fn turn_bytes_count_newlines_blank_and_malformed_lines_only_while_active() {
let mut idle = TurnBytes {
limit: Some(3),
used: None,
};
let (frames, failure) = frames_of(b"\n\n\n\nnot json\n", None, &mut idle).await;
assert_eq!(frames.len(), 5);
assert!(failure.is_none(), "idle bytes must not count");
let mut active = TurnBytes {
limit: Some(10),
used: None,
};
active.start();
let (frames, failure) = frames_of(b"\n\n\n\nnot json\n", None, &mut active).await;
assert_eq!(frames.len(), 4, "the malformed line would reach 13 bytes");
assert!(matches!(
failure,
Some(PolicyFailure::Limit {
limit: DuplexLimit::TurnBytes,
max: 10
})
));
active.stop();
active.start();
assert_eq!(active.used, Some(0), "the budget resets per turn");
}
#[tokio::test]
async fn frame_reader_keeps_partial_bytes_across_a_cancelled_read() {
let (mut writer, reader) = tokio::io::duplex(64);
let mut frames = FrameReader::new(reader, None);
let mut turn = TurnBytes::default();
writer.write_all(br#"{"type":"#).await.unwrap();
let cancelled =
tokio::time::timeout(Duration::from_millis(20), frames.next_frame(&mut turn)).await;
assert!(cancelled.is_err(), "no complete frame is available yet");
writer.write_all(b"\"assistant\"}\n").await.unwrap();
match frames.next_frame(&mut turn).await {
Ok(FrameRead::Frame(frame)) => assert_eq!(frame, br#"{"type":"assistant"}"#),
_ => panic!("expected the reassembled frame"),
}
}
#[test]
fn event_limit_stops_retention_and_broadcast_but_not_results_or_acks() {
let (tx, rx) = oneshot::channel::<Result<TurnResult>>();
let (events_tx, mut events_rx) = broadcast::channel(16);
let mut pending = Some((tx, Vec::new()));
let limit = Some(1);
assert!(matches!(
handle_inbound(
json!({"type": "assistant"}),
&mut pending,
&events_tx,
limit
),
InboundAction::None
));
assert!(matches!(
handle_inbound(
json!({"type": "assistant"}),
&mut pending,
&events_tx,
limit
),
InboundAction::EventLimit(1)
));
let ack = json!({
"type": "control_response",
"response": {"request_id": "interrupt-1", "subtype": "success"}
});
assert!(matches!(
handle_inbound(ack, &mut pending, &events_tx, limit),
InboundAction::ControlResponse { .. }
));
assert!(events_rx.try_recv().is_ok());
assert!(events_rx.try_recv().is_err(), "overflow must not broadcast");
assert!(matches!(
handle_inbound(json!({"type": "result"}), &mut pending, &events_tx, limit),
InboundAction::None
));
assert_eq!(rx.blocking_recv().unwrap().unwrap().events.len(), 1);
}
#[test]
fn policy_failures_render_distinguishable_errors() {
let limit = PolicyFailure::Limit {
limit: DuplexLimit::TurnEvents,
max: 7,
}
.error(true);
assert!(matches!(
limit,
Error::DuplexLimitExceeded {
limit: DuplexLimit::TurnEvents,
max: 7,
process_settled: true,
..
}
));
let deadline = PolicyFailure::Deadline {
operation: DuplexOperation::Control,
timeout: Duration::from_millis(5),
}
.error(false);
assert!(matches!(
deadline,
Error::DuplexDeadlineExceeded {
operation: DuplexOperation::Control,
process_settled: false,
..
}
));
assert!(deadline.to_string().contains("control request deadline"));
}
#[tokio::test]
async fn turn_budget_counts_a_partial_frame_started_while_idle() {
let (mut writer, reader) = tokio::io::duplex(128);
let mut frames = FrameReader::new(reader, None);
let mut turn = TurnBytes {
limit: Some(20),
used: None,
};
let prefix = br#"{"type":"assistant","text":"a long prefix"#;
writer.write_all(prefix).await.unwrap();
tokio::select! {
biased;
_ = frames.next_frame(&mut turn) => panic!("frame is incomplete"),
_ = tokio::task::yield_now() => {},
}
assert_eq!(frames.partial, prefix);
turn.start();
writer.write_all(b"\"}\n").await.unwrap();
assert!(
matches!(
frames.next_frame(&mut turn).await,
Err(FrameError::Limit(PolicyFailure::Limit {
limit: DuplexLimit::TurnBytes,
..
}))
),
"the retained frame exceeds the turn budget even though its prefix was read while idle"
);
}
}