Skip to main content

claude_wrapper/
streaming.rs

1//! NDJSON streaming of `claude` events.
2//!
3//! [`stream_query`] (and its blocking peer `stream_query_sync`) run a
4//! query in `stream-json` mode and hand each decoded event to a
5//! caller-supplied callback as it arrives, rather than buffering the
6//! whole run. Requires the `json` feature.
7
8#[cfg(feature = "json")]
9use std::time::Duration;
10
11#[cfg(all(feature = "json", feature = "async"))]
12use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
13#[cfg(all(feature = "json", feature = "async"))]
14use tokio::process::{ChildStderr, Command};
15#[cfg(feature = "json")]
16use tracing::{debug, warn};
17
18#[cfg(feature = "json")]
19use crate::Claude;
20#[cfg(feature = "json")]
21use crate::error::{Error, Result};
22#[cfg(feature = "json")]
23use crate::exec::CommandOutput;
24
25/// A single line from `--output-format stream-json` output.
26///
27/// Each line is an NDJSON object. The structure varies by message type,
28/// so we provide the raw JSON value and convenience accessors.
29#[cfg(feature = "json")]
30#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
31pub struct StreamEvent {
32    /// The raw JSON object for this event.
33    #[serde(flatten)]
34    pub data: serde_json::Value,
35}
36
37#[cfg(feature = "json")]
38impl StreamEvent {
39    /// Get the event type, if present.
40    pub fn event_type(&self) -> Option<&str> {
41        self.data.get("type").and_then(|v| v.as_str())
42    }
43
44    /// Get the message role, if present.
45    pub fn role(&self) -> Option<&str> {
46        self.data.get("role").and_then(|v| v.as_str())
47    }
48
49    /// Check if this is the final result message.
50    pub fn is_result(&self) -> bool {
51        self.event_type() == Some("result")
52    }
53
54    /// Extract the result text from a result event.
55    pub fn result_text(&self) -> Option<&str> {
56        self.data.get("result").and_then(|v| v.as_str())
57    }
58
59    /// Get the session ID if present.
60    pub fn session_id(&self) -> Option<&str> {
61        self.data.get("session_id").and_then(|v| v.as_str())
62    }
63
64    /// Get the cost in USD if present (usually on result events).
65    ///
66    /// Prefers `total_cost_usd` (the CLI's primary key) and falls back
67    /// to the legacy `cost_usd` alias.
68    pub fn cost_usd(&self) -> Option<f64> {
69        self.data
70            .get("total_cost_usd")
71            .or_else(|| self.data.get("cost_usd"))
72            .and_then(|v| v.as_f64())
73    }
74
75    /// Decode a partial-message event into a typed view.
76    ///
77    /// Returns `Some` when the event is one of the content-block lifecycle
78    /// events surfaced by [`QueryCommand::include_partial_messages`] -- start,
79    /// delta, or stop. Returns `None` for any other event (system, assistant,
80    /// result, message-level stream events, etc).
81    ///
82    /// The CLI wraps each raw streaming event as
83    /// `{"type":"stream_event","event":{...}}`; this accessor unwraps that
84    /// envelope. Unknown block types and unknown delta types fall through to
85    /// [`BlockType::Other`] / [`BlockDelta::Other`] rather than erroring, so
86    /// future content-block kinds remain accessible (just untyped).
87    ///
88    /// # Example
89    ///
90    /// Pull incremental thinking text out of a partial-message event:
91    ///
92    /// ```
93    /// use claude_wrapper::streaming::{BlockDelta, PartialMessageEvent, StreamEvent};
94    /// use serde_json::json;
95    ///
96    /// let event: StreamEvent = serde_json::from_value(json!({
97    ///     "type": "stream_event",
98    ///     "event": {
99    ///         "type": "content_block_delta",
100    ///         "index": 0,
101    ///         "delta": { "type": "thinking_delta", "thinking": "Let me think..." }
102    ///     },
103    ///     "session_id": "abc"
104    /// })).unwrap();
105    ///
106    /// match event.partial_message() {
107    ///     Some(PartialMessageEvent::BlockDelta { delta: BlockDelta::Thinking(t), .. }) => {
108    ///         assert_eq!(t, "Let me think...");
109    ///     }
110    ///     _ => unreachable!(),
111    /// }
112    /// ```
113    ///
114    /// [`QueryCommand::include_partial_messages`]: crate::QueryCommand::include_partial_messages
115    pub fn partial_message(&self) -> Option<PartialMessageEvent> {
116        let event = if self.event_type() == Some("stream_event") {
117            self.data.get("event")?
118        } else {
119            &self.data
120        };
121
122        let inner_type = event.get("type")?.as_str()?;
123        let index = event.get("index").and_then(serde_json::Value::as_u64)?;
124        let index = u32::try_from(index).ok()?;
125
126        match inner_type {
127            "content_block_start" => {
128                let block_type = parse_block_type(event.get("content_block")?);
129                Some(PartialMessageEvent::BlockStart { index, block_type })
130            }
131            "content_block_delta" => {
132                let delta = parse_block_delta(event.get("delta")?);
133                Some(PartialMessageEvent::BlockDelta { index, delta })
134            }
135            "content_block_stop" => Some(PartialMessageEvent::BlockStop { index }),
136            _ => None,
137        }
138    }
139}
140
141/// A decoded partial-message event from a streaming `claude` call.
142///
143/// Surfaced by [`StreamEvent::partial_message`] when `--include-partial-messages`
144/// is set. The three variants correspond to the Anthropic streaming content-block
145/// lifecycle: a block starts, gets one or more deltas, then stops.
146#[cfg(feature = "json")]
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum PartialMessageEvent {
149    /// A new content block is starting. `block_type` says what kind.
150    BlockStart {
151        /// Position of this block within the assistant message.
152        index: u32,
153        /// What kind of block is starting (text, thinking, tool use, ...).
154        block_type: BlockType,
155    },
156    /// Incremental content for an in-progress block.
157    BlockDelta {
158        /// Index of the block this delta applies to (matches a prior [`BlockStart`]).
159        ///
160        /// [`BlockStart`]: PartialMessageEvent::BlockStart
161        index: u32,
162        /// The incremental payload.
163        delta: BlockDelta,
164    },
165    /// The block at `index` is complete.
166    BlockStop {
167        /// Index of the block that just finished.
168        index: u32,
169    },
170}
171
172/// The kind of content block reported by a [`PartialMessageEvent::BlockStart`].
173///
174/// Mirrors the `content_block.type` field from the Anthropic streaming API.
175/// New block kinds added upstream surface as [`BlockType::Other`] -- callers
176/// can still recover the type name from the carried string.
177#[cfg(feature = "json")]
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum BlockType {
180    /// Regular assistant text -- followed by `text_delta` deltas.
181    Text,
182    /// Extended-thinking block -- followed by `thinking_delta` deltas.
183    Thinking,
184    /// A tool invocation -- followed by `input_json_delta` deltas streaming the JSON input.
185    ToolUse {
186        /// Tool-call id, used to correlate the eventual tool result.
187        id: String,
188        /// Name of the tool being called.
189        name: String,
190    },
191    /// Any block type not yet modelled. Carries the raw `type` string.
192    Other(String),
193}
194
195/// The incremental payload carried by a [`PartialMessageEvent::BlockDelta`].
196///
197/// Mirrors the `delta.type` field from the Anthropic streaming API.
198/// Less-common delta kinds (signature, citations, compaction, ...) collapse to
199/// [`BlockDelta::Other`]; callers that need them can fall back to
200/// [`StreamEvent::data`].
201#[cfg(feature = "json")]
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum BlockDelta {
204    /// Chunk of assistant text.
205    Text(String),
206    /// Chunk of extended-thinking text.
207    Thinking(String),
208    /// Chunk of streaming tool-input JSON. Concatenate across deltas to
209    /// reconstruct the full input -- individual chunks are not standalone JSON.
210    InputJson(String),
211    /// Any delta type not modelled above (e.g. `signature_delta`,
212    /// `citations_delta`). Read from [`StreamEvent::data`] for the raw payload.
213    Other,
214}
215
216#[cfg(feature = "json")]
217fn parse_block_type(content_block: &serde_json::Value) -> BlockType {
218    let Some(ty) = content_block
219        .get("type")
220        .and_then(serde_json::Value::as_str)
221    else {
222        return BlockType::Other(String::new());
223    };
224    match ty {
225        "text" => BlockType::Text,
226        "thinking" => BlockType::Thinking,
227        "tool_use" => {
228            let id = content_block
229                .get("id")
230                .and_then(serde_json::Value::as_str)
231                .unwrap_or("")
232                .to_string();
233            let name = content_block
234                .get("name")
235                .and_then(serde_json::Value::as_str)
236                .unwrap_or("")
237                .to_string();
238            BlockType::ToolUse { id, name }
239        }
240        other => BlockType::Other(other.to_string()),
241    }
242}
243
244#[cfg(feature = "json")]
245fn parse_block_delta(delta: &serde_json::Value) -> BlockDelta {
246    let Some(ty) = delta.get("type").and_then(serde_json::Value::as_str) else {
247        return BlockDelta::Other;
248    };
249    match ty {
250        "text_delta" => delta
251            .get("text")
252            .and_then(serde_json::Value::as_str)
253            .map(|s| BlockDelta::Text(s.to_string()))
254            .unwrap_or(BlockDelta::Other),
255        "thinking_delta" => delta
256            .get("thinking")
257            .and_then(serde_json::Value::as_str)
258            .map(|s| BlockDelta::Thinking(s.to_string()))
259            .unwrap_or(BlockDelta::Other),
260        "input_json_delta" => delta
261            .get("partial_json")
262            .and_then(serde_json::Value::as_str)
263            .map(|s| BlockDelta::InputJson(s.to_string()))
264            .unwrap_or(BlockDelta::Other),
265        _ => BlockDelta::Other,
266    }
267}
268
269/// Execute a command with streaming output, calling a handler for each NDJSON line.
270///
271/// This spawns the claude process and reads stdout line-by-line, parsing each
272/// as a JSON event and passing it to the handler. Useful for progress tracking
273/// and real-time output processing.
274///
275/// # Example
276///
277/// ```no_run
278/// use claude_wrapper::{Claude, QueryCommand, OutputFormat};
279/// use claude_wrapper::streaming::{StreamEvent, stream_query};
280///
281/// # async fn example() -> claude_wrapper::Result<()> {
282/// let claude = Claude::builder().build()?;
283///
284/// let cmd = QueryCommand::new("explain quicksort")
285///     .output_format(OutputFormat::StreamJson);
286///
287/// let output = stream_query(&claude, &cmd, |event: StreamEvent| {
288///     if let Some(t) = event.event_type() {
289///         println!("[{t}] {:?}", event.data);
290///     }
291/// }).await?;
292/// # Ok(())
293/// # }
294/// ```
295#[cfg(all(feature = "json", feature = "async"))]
296pub async fn stream_query<F>(
297    claude: &Claude,
298    cmd: &crate::command::query::QueryCommand,
299    handler: F,
300) -> Result<CommandOutput>
301where
302    F: FnMut(StreamEvent),
303{
304    stream_query_impl(claude, cmd, handler, claude.timeout).await
305}
306
307/// Unified streaming implementation with optional timeout.
308///
309/// Reads stderr concurrently in a background task so a chatty child
310/// cannot deadlock by filling the stderr pipe buffer, and so any
311/// captured stderr is available even on timeout or IO error.
312///
313/// On timeout, the child is killed and reaped (`kill().await` sends
314/// SIGKILL and waits), and whatever stderr was produced is logged at
315/// warn level. The returned `Error::Timeout` does not carry partial
316/// output -- streamed stdout events were already dispatched to the
317/// handler as they arrived.
318#[cfg(all(feature = "json", feature = "async"))]
319async fn stream_query_impl<F>(
320    claude: &Claude,
321    cmd: &crate::command::query::QueryCommand,
322    mut handler: F,
323    timeout: Option<Duration>,
324) -> Result<CommandOutput>
325where
326    F: FnMut(StreamEvent),
327{
328    use crate::command::ClaudeCommand;
329
330    let args = cmd.args();
331
332    let mut command_args = Vec::new();
333    command_args.extend(claude.global_args.clone());
334    command_args.extend(args);
335
336    debug!(
337        binary = %claude.binary.display(),
338        args = ?command_args,
339        timeout = ?timeout,
340        "streaming claude command"
341    );
342
343    let mut cmd = Command::new(&claude.binary);
344    cmd.args(&command_args)
345        .env_remove("CLAUDECODE")
346        .envs(&claude.env)
347        .stdout(std::process::Stdio::piped())
348        .stderr(std::process::Stdio::piped())
349        .stdin(std::process::Stdio::null());
350
351    if let Some(ref dir) = claude.working_dir {
352        cmd.current_dir(dir);
353    }
354
355    let mut child = cmd.spawn().map_err(|e| Error::Io {
356        message: format!("failed to spawn claude: {e}"),
357        source: e,
358        working_dir: claude.working_dir.clone(),
359    })?;
360
361    let stdout = child.stdout.take().expect("stdout was piped");
362    let mut stderr = child.stderr.take().expect("stderr was piped");
363
364    let mut reader = BufReader::new(stdout).lines();
365
366    // Run stdout line reading and stderr draining concurrently so a
367    // chatty child can't deadlock by filling the stderr pipe buffer.
368    // tokio::join! polls both futures on the same task (no tokio::spawn
369    // needed, so we avoid pulling in the `rt` feature).
370    let drain = drain_stderr(&mut stderr);
371    let read_future = read_lines(&mut reader, &mut handler, claude.working_dir.clone());
372    let combined = async {
373        let (line_result, stderr_str) = tokio::join!(read_future, drain);
374        (line_result, stderr_str)
375    };
376
377    let (line_result, stderr_str) = match timeout {
378        Some(d) => match tokio::time::timeout(d, combined).await {
379            Ok(pair) => pair,
380            Err(_) => {
381                // Timeout: kill the child (reaps via start_kill + wait)
382                // and try to drain whatever stderr remains. kill() only
383                // targets the direct child, so a subprocess tree holding
384                // our pipe fds could block the drain -- cap it with a
385                // short deadline.
386                let _ = child.kill().await;
387                let drain_budget = Duration::from_millis(200);
388                let stderr_str = tokio::time::timeout(drain_budget, drain_stderr(&mut stderr))
389                    .await
390                    .unwrap_or_default();
391                if !stderr_str.is_empty() {
392                    warn!(stderr = %stderr_str, "stderr from timed-out streaming process");
393                }
394                return Err(Error::Timeout {
395                    timeout_seconds: d.as_secs(),
396                });
397            }
398        },
399        None => combined.await,
400    };
401
402    // If reading lines failed partway through (IO error, not timeout),
403    // clean up the child before returning.
404    if let Err(e) = line_result {
405        let _ = child.kill().await;
406        return Err(e);
407    }
408
409    let status = child.wait().await.map_err(|e| Error::Io {
410        message: "failed to wait for claude process".to_string(),
411        source: e,
412        working_dir: claude.working_dir.clone(),
413    })?;
414
415    let exit_code = status.code().unwrap_or(-1);
416
417    if !status.success() {
418        return Err(Error::CommandFailed {
419            command: format!("{} {}", claude.binary.display(), command_args.join(" ")),
420            exit_code,
421            stdout: String::new(),
422            stderr: stderr_str,
423            working_dir: claude.working_dir.clone(),
424        });
425    }
426
427    Ok(CommandOutput {
428        stdout: String::new(), // already consumed via streaming
429        stderr: stderr_str,
430        exit_code,
431        success: true,
432    })
433}
434
435#[cfg(all(feature = "json", feature = "async"))]
436async fn drain_stderr(stderr: &mut ChildStderr) -> String {
437    let mut buf = Vec::new();
438    let _ = stderr.read_to_end(&mut buf).await;
439    String::from_utf8_lossy(&buf).into_owned()
440}
441
442#[cfg(all(feature = "json", feature = "async"))]
443async fn read_lines<F>(
444    reader: &mut tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
445    handler: &mut F,
446    working_dir: Option<std::path::PathBuf>,
447) -> Result<()>
448where
449    F: FnMut(StreamEvent),
450{
451    while let Some(line) = reader.next_line().await.map_err(|e| Error::Io {
452        message: "failed to read stdout line".to_string(),
453        source: e,
454        working_dir: working_dir.clone(),
455    })? {
456        if line.trim().is_empty() {
457            continue;
458        }
459        match serde_json::from_str::<StreamEvent>(&line) {
460            Ok(event) => handler(event),
461            Err(e) => {
462                debug!(line = %line, error = %e, "failed to parse stream event, skipping");
463            }
464        }
465    }
466
467    Ok(())
468}
469
470// ---------- sync streaming ----------
471
472/// Blocking mirror of [`stream_query`]. Reads NDJSON lines from the
473/// child's stdout on a worker thread, dispatches each parsed event
474/// to `handler` on the caller's thread, and drains stderr on a
475/// separate worker thread so the child can't deadlock on a full pipe.
476///
477/// Requires both `sync` and `json` features.
478///
479/// The handler is invoked on the caller's thread — no `Send` bound —
480/// so it can capture non-`Send` state. If a timeout is configured on
481/// the [`Claude`] client, the child is SIGKILLed and reaped once the
482/// deadline passes; partial events already dispatched to the handler
483/// are not rolled back.
484///
485/// # Example
486///
487/// ```no_run
488/// # #[cfg(all(feature = "sync", feature = "json"))]
489/// # {
490/// use claude_wrapper::{Claude, OutputFormat, QueryCommand};
491/// use claude_wrapper::streaming::{StreamEvent, stream_query_sync};
492///
493/// # fn example() -> claude_wrapper::Result<()> {
494/// let claude = Claude::builder().build()?;
495/// let cmd = QueryCommand::new("explain quicksort")
496///     .output_format(OutputFormat::StreamJson);
497///
498/// stream_query_sync(&claude, &cmd, |event: StreamEvent| {
499///     if let Some(t) = event.event_type() {
500///         println!("[{t}] {:?}", event.data);
501///     }
502/// })?;
503/// # Ok(())
504/// # }
505/// # }
506/// ```
507#[cfg(all(feature = "sync", feature = "json"))]
508pub fn stream_query_sync<F>(
509    claude: &Claude,
510    cmd: &crate::command::query::QueryCommand,
511    mut handler: F,
512) -> Result<CommandOutput>
513where
514    F: FnMut(StreamEvent),
515{
516    use std::io::{BufRead as _, Read as _};
517    use std::process::{Command as StdCommand, Stdio};
518    use std::sync::mpsc;
519    use std::thread;
520    use std::time::Instant;
521
522    use crate::command::ClaudeCommand;
523
524    let args = cmd.args();
525    let mut command_args = Vec::new();
526    command_args.extend(claude.global_args.clone());
527    command_args.extend(args);
528
529    debug!(
530        binary = %claude.binary.display(),
531        args = ?command_args,
532        timeout = ?claude.timeout,
533        "streaming claude command (sync)"
534    );
535
536    let mut cmd_builder = StdCommand::new(&claude.binary);
537    cmd_builder
538        .args(&command_args)
539        .env_remove("CLAUDECODE")
540        .env_remove("CLAUDE_CODE_ENTRYPOINT")
541        .envs(&claude.env)
542        .stdin(Stdio::null())
543        .stdout(Stdio::piped())
544        .stderr(Stdio::piped());
545
546    if let Some(ref dir) = claude.working_dir {
547        cmd_builder.current_dir(dir);
548    }
549
550    let mut child = cmd_builder.spawn().map_err(|e| Error::Io {
551        message: format!("failed to spawn claude: {e}"),
552        source: e,
553        working_dir: claude.working_dir.clone(),
554    })?;
555
556    let stdout = child.stdout.take().expect("stdout was piped");
557    let stderr = child.stderr.take().expect("stderr was piped");
558
559    // Reader thread: parse NDJSON lines and push StreamEvents through
560    // the channel. Handler runs on the caller's thread so it doesn't
561    // need Send. Bubbles IO errors out via the thread's return value.
562    let (tx, rx) = mpsc::channel::<StreamEvent>();
563    let reader_wd = claude.working_dir.clone();
564    let reader_thread = thread::spawn(move || -> Result<()> {
565        let reader = std::io::BufReader::new(stdout);
566        for line_res in reader.lines() {
567            let line = line_res.map_err(|e| Error::Io {
568                message: "failed to read stdout line".to_string(),
569                source: e,
570                working_dir: reader_wd.clone(),
571            })?;
572            if line.trim().is_empty() {
573                continue;
574            }
575            match serde_json::from_str::<StreamEvent>(&line) {
576                Ok(event) => {
577                    if tx.send(event).is_err() {
578                        // Receiver gone — main thread has bailed out.
579                        return Ok(());
580                    }
581                }
582                Err(e) => {
583                    debug!(line = %line, error = %e, "failed to parse stream event, skipping");
584                }
585            }
586        }
587        Ok(())
588    });
589
590    let stderr_thread = thread::spawn(move || -> String {
591        let mut buf = Vec::new();
592        let mut stderr = stderr;
593        let _ = stderr.read_to_end(&mut buf);
594        String::from_utf8_lossy(&buf).into_owned()
595    });
596
597    // Main loop: dispatch events on the caller's thread, honouring the
598    // configured timeout. Break on disconnect (reader done) or timeout.
599    let deadline = claude.timeout.map(|d| Instant::now() + d);
600    let mut timed_out = false;
601
602    loop {
603        let recv_result = match deadline {
604            Some(d) => {
605                let now = Instant::now();
606                if now >= d {
607                    timed_out = true;
608                    break;
609                }
610                rx.recv_timeout(d - now)
611            }
612            None => rx.recv().map_err(|_| mpsc::RecvTimeoutError::Disconnected),
613        };
614
615        match recv_result {
616            Ok(event) => handler(event),
617            Err(mpsc::RecvTimeoutError::Timeout) => {
618                timed_out = true;
619                break;
620            }
621            Err(mpsc::RecvTimeoutError::Disconnected) => break,
622        }
623    }
624
625    if timed_out {
626        let _ = child.kill();
627        let _ = child.wait();
628        // Both worker threads can block indefinitely if an orphaned
629        // grandchild inherited our pipe fds and keeps the write end
630        // open (e.g. a `bash` script whose `sleep` subprocess outlives
631        // the SIGKILLed shell). Cap the joins so the timeout error
632        // still returns promptly; any thread that misses the deadline
633        // leaks its JoinHandle, which is acceptable for this edge.
634        let budget = Duration::from_millis(200);
635        let stderr_str = join_with_budget(stderr_thread, budget).unwrap_or_default();
636        let _ = join_with_budget(reader_thread, budget);
637        if !stderr_str.is_empty() {
638            warn!(stderr = %stderr_str, "stderr from timed-out streaming process");
639        }
640        return Err(Error::Timeout {
641            timeout_seconds: claude.timeout.map(|d| d.as_secs()).unwrap_or_default(),
642        });
643    }
644
645    // Normal completion: collect reader result (may carry IO error).
646    let reader_result = reader_thread.join().unwrap_or(Ok(()));
647    if let Err(e) = reader_result {
648        let _ = child.kill();
649        let _ = child.wait();
650        let _ = stderr_thread.join();
651        return Err(e);
652    }
653
654    let status = child.wait().map_err(|e| Error::Io {
655        message: "failed to wait for claude process".to_string(),
656        source: e,
657        working_dir: claude.working_dir.clone(),
658    })?;
659    let stderr_str = stderr_thread.join().unwrap_or_default();
660    let exit_code = status.code().unwrap_or(-1);
661
662    if !status.success() {
663        return Err(Error::CommandFailed {
664            command: format!("{} {}", claude.binary.display(), command_args.join(" ")),
665            exit_code,
666            stdout: String::new(),
667            stderr: stderr_str,
668            working_dir: claude.working_dir.clone(),
669        });
670    }
671
672    Ok(CommandOutput {
673        stdout: String::new(),
674        stderr: stderr_str,
675        exit_code,
676        success: true,
677    })
678}
679
680/// Join a worker thread with a time budget. Returns `Some(value)` if
681/// the thread finished in time, `None` if the deadline passed first.
682/// A missed deadline leaks the `JoinHandle`; the thread completes
683/// eventually and its value is dropped.
684#[cfg(all(feature = "sync", feature = "json"))]
685fn join_with_budget<T: Send + 'static>(
686    handle: std::thread::JoinHandle<T>,
687    budget: Duration,
688) -> Option<T> {
689    use std::sync::mpsc;
690    use std::thread;
691
692    let (tx, rx) = mpsc::channel::<T>();
693    thread::spawn(move || {
694        if let Ok(v) = handle.join() {
695            let _ = tx.send(v);
696        }
697    });
698    rx.recv_timeout(budget).ok()
699}
700
701#[cfg(all(test, feature = "json"))]
702mod tests {
703    use super::*;
704    use serde_json::json;
705
706    fn parse(v: serde_json::Value) -> StreamEvent {
707        serde_json::from_value(v).expect("valid StreamEvent")
708    }
709
710    fn wrap(inner: serde_json::Value) -> StreamEvent {
711        parse(json!({
712            "type": "stream_event",
713            "event": inner,
714            "session_id": "sess-1",
715            "parent_tool_use_id": null,
716            "uuid": "11111111-1111-1111-1111-111111111111"
717        }))
718    }
719
720    #[test]
721    fn partial_message_text_block_lifecycle() {
722        let start = wrap(json!({
723            "type": "content_block_start",
724            "index": 0,
725            "content_block": { "type": "text", "text": "" }
726        }));
727        assert_eq!(
728            start.partial_message(),
729            Some(PartialMessageEvent::BlockStart {
730                index: 0,
731                block_type: BlockType::Text,
732            })
733        );
734
735        let delta = wrap(json!({
736            "type": "content_block_delta",
737            "index": 0,
738            "delta": { "type": "text_delta", "text": "Hello" }
739        }));
740        assert_eq!(
741            delta.partial_message(),
742            Some(PartialMessageEvent::BlockDelta {
743                index: 0,
744                delta: BlockDelta::Text("Hello".into()),
745            })
746        );
747
748        let stop = wrap(json!({ "type": "content_block_stop", "index": 0 }));
749        assert_eq!(
750            stop.partial_message(),
751            Some(PartialMessageEvent::BlockStop { index: 0 })
752        );
753    }
754
755    #[test]
756    fn partial_message_thinking_block_lifecycle() {
757        let start = wrap(json!({
758            "type": "content_block_start",
759            "index": 1,
760            "content_block": { "type": "thinking", "thinking": "", "signature": "" }
761        }));
762        assert_eq!(
763            start.partial_message(),
764            Some(PartialMessageEvent::BlockStart {
765                index: 1,
766                block_type: BlockType::Thinking,
767            })
768        );
769
770        let delta = wrap(json!({
771            "type": "content_block_delta",
772            "index": 1,
773            "delta": { "type": "thinking_delta", "thinking": "weighing options" }
774        }));
775        assert_eq!(
776            delta.partial_message(),
777            Some(PartialMessageEvent::BlockDelta {
778                index: 1,
779                delta: BlockDelta::Thinking("weighing options".into()),
780            })
781        );
782
783        let stop = wrap(json!({ "type": "content_block_stop", "index": 1 }));
784        assert_eq!(
785            stop.partial_message(),
786            Some(PartialMessageEvent::BlockStop { index: 1 })
787        );
788    }
789
790    #[test]
791    fn partial_message_tool_use_block_carries_id_and_name() {
792        let start = wrap(json!({
793            "type": "content_block_start",
794            "index": 2,
795            "content_block": {
796                "type": "tool_use",
797                "id": "toolu_abc",
798                "name": "Bash",
799                "input": {}
800            }
801        }));
802        assert_eq!(
803            start.partial_message(),
804            Some(PartialMessageEvent::BlockStart {
805                index: 2,
806                block_type: BlockType::ToolUse {
807                    id: "toolu_abc".into(),
808                    name: "Bash".into(),
809                },
810            })
811        );
812
813        let delta = wrap(json!({
814            "type": "content_block_delta",
815            "index": 2,
816            "delta": { "type": "input_json_delta", "partial_json": "{\"cmd\":" }
817        }));
818        assert_eq!(
819            delta.partial_message(),
820            Some(PartialMessageEvent::BlockDelta {
821                index: 2,
822                delta: BlockDelta::InputJson("{\"cmd\":".into()),
823            })
824        );
825    }
826
827    #[test]
828    fn partial_message_unknown_kinds_fall_through_to_other() {
829        let unknown_block = wrap(json!({
830            "type": "content_block_start",
831            "index": 3,
832            "content_block": { "type": "redacted_thinking", "data": "..." }
833        }));
834        assert_eq!(
835            unknown_block.partial_message(),
836            Some(PartialMessageEvent::BlockStart {
837                index: 3,
838                block_type: BlockType::Other("redacted_thinking".into()),
839            })
840        );
841
842        let unknown_delta = wrap(json!({
843            "type": "content_block_delta",
844            "index": 3,
845            "delta": { "type": "signature_delta", "signature": "sig" }
846        }));
847        assert_eq!(
848            unknown_delta.partial_message(),
849            Some(PartialMessageEvent::BlockDelta {
850                index: 3,
851                delta: BlockDelta::Other,
852            })
853        );
854    }
855
856    #[test]
857    fn partial_message_returns_none_for_non_partial_events() {
858        let result = parse(json!({
859            "type": "result",
860            "result": "done",
861            "session_id": "sess-1",
862            "total_cost_usd": 0.01
863        }));
864        assert!(result.partial_message().is_none());
865
866        let assistant = parse(json!({
867            "type": "assistant",
868            "message": { "role": "assistant", "content": [] },
869            "session_id": "sess-1"
870        }));
871        assert!(assistant.partial_message().is_none());
872
873        let message_start = wrap(json!({
874            "type": "message_start",
875            "message": { "id": "msg_1", "role": "assistant", "content": [] }
876        }));
877        assert!(message_start.partial_message().is_none());
878    }
879
880    #[test]
881    fn partial_message_accepts_unwrapped_event() {
882        let raw = parse(json!({
883            "type": "content_block_delta",
884            "index": 0,
885            "delta": { "type": "text_delta", "text": "hi" }
886        }));
887        assert_eq!(
888            raw.partial_message(),
889            Some(PartialMessageEvent::BlockDelta {
890                index: 0,
891                delta: BlockDelta::Text("hi".into()),
892            })
893        );
894    }
895}