1#[cfg(feature = "json")]
9use std::collections::VecDeque;
10#[cfg(feature = "json")]
11use std::time::Duration;
12
13#[cfg(all(feature = "json", feature = "async"))]
14use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
15#[cfg(all(feature = "json", feature = "async"))]
16use tokio::process::{ChildStderr, Command};
17#[cfg(feature = "json")]
18use tracing::{debug, warn};
19
20#[cfg(feature = "json")]
21use crate::Claude;
22#[cfg(feature = "json")]
23use crate::error::{Error, Result};
24#[cfg(feature = "json")]
25use crate::exec::CommandOutput;
26
27#[cfg(feature = "json")]
28const STREAM_DIAGNOSTIC_MAX_BYTES: usize = 16 * 1024;
29
30#[cfg(feature = "json")]
32#[derive(Default)]
33struct ParseFailureDiagnostics {
34 lines: VecDeque<String>,
35 bytes: usize,
36}
37
38#[cfg(feature = "json")]
39impl ParseFailureDiagnostics {
40 fn push(&mut self, line: &str) {
41 let max_line_bytes = STREAM_DIAGNOSTIC_MAX_BYTES - 1;
45 let line = if line.len() > max_line_bytes {
46 let mut end = max_line_bytes;
47 while !line.is_char_boundary(end) {
48 end -= 1;
49 }
50 &line[..end]
51 } else {
52 line
53 };
54 let line_bytes = line.len() + 1;
55
56 while self.bytes + line_bytes > STREAM_DIAGNOSTIC_MAX_BYTES {
57 let Some(removed) = self.lines.pop_front() else {
58 break;
59 };
60 self.bytes -= removed.len() + 1;
61 }
62
63 self.lines.push_back(line.to_string());
64 self.bytes += line_bytes;
65 }
66
67 fn into_string(self) -> String {
68 let mut output = String::with_capacity(self.bytes);
69 for line in self.lines {
70 output.push_str(&line);
71 output.push('\n');
72 }
73 output.pop();
74 output
75 }
76}
77
78#[cfg(feature = "json")]
83#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
84pub struct StreamEvent {
85 #[serde(flatten)]
87 pub data: serde_json::Value,
88}
89
90#[cfg(feature = "json")]
91impl StreamEvent {
92 pub fn event_type(&self) -> Option<&str> {
94 self.data.get("type").and_then(|v| v.as_str())
95 }
96
97 pub fn role(&self) -> Option<&str> {
99 self.data.get("role").and_then(|v| v.as_str())
100 }
101
102 pub fn is_result(&self) -> bool {
104 self.event_type() == Some("result")
105 }
106
107 pub fn result_text(&self) -> Option<&str> {
109 self.data.get("result").and_then(|v| v.as_str())
110 }
111
112 pub fn session_id(&self) -> Option<&str> {
114 self.data.get("session_id").and_then(|v| v.as_str())
115 }
116
117 pub fn cost_usd(&self) -> Option<f64> {
122 self.data
123 .get("total_cost_usd")
124 .or_else(|| self.data.get("cost_usd"))
125 .and_then(|v| v.as_f64())
126 }
127
128 pub fn partial_message(&self) -> Option<PartialMessageEvent> {
169 let event = if self.event_type() == Some("stream_event") {
170 self.data.get("event")?
171 } else {
172 &self.data
173 };
174
175 let inner_type = event.get("type")?.as_str()?;
176 let index = event.get("index").and_then(serde_json::Value::as_u64)?;
177 let index = u32::try_from(index).ok()?;
178
179 match inner_type {
180 "content_block_start" => {
181 let block_type = parse_block_type(event.get("content_block")?);
182 Some(PartialMessageEvent::BlockStart { index, block_type })
183 }
184 "content_block_delta" => {
185 let delta = parse_block_delta(event.get("delta")?);
186 Some(PartialMessageEvent::BlockDelta { index, delta })
187 }
188 "content_block_stop" => Some(PartialMessageEvent::BlockStop { index }),
189 _ => None,
190 }
191 }
192}
193
194#[cfg(feature = "json")]
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum PartialMessageEvent {
202 BlockStart {
204 index: u32,
206 block_type: BlockType,
208 },
209 BlockDelta {
211 index: u32,
215 delta: BlockDelta,
217 },
218 BlockStop {
220 index: u32,
222 },
223}
224
225#[cfg(feature = "json")]
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum BlockType {
233 Text,
235 Thinking,
237 ToolUse {
239 id: String,
241 name: String,
243 },
244 Other(String),
246}
247
248#[cfg(feature = "json")]
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum BlockDelta {
257 Text(String),
259 Thinking(String),
261 InputJson(String),
264 Other,
267}
268
269#[cfg(feature = "json")]
270fn parse_block_type(content_block: &serde_json::Value) -> BlockType {
271 let Some(ty) = content_block
272 .get("type")
273 .and_then(serde_json::Value::as_str)
274 else {
275 return BlockType::Other(String::new());
276 };
277 match ty {
278 "text" => BlockType::Text,
279 "thinking" => BlockType::Thinking,
280 "tool_use" => {
281 let id = content_block
282 .get("id")
283 .and_then(serde_json::Value::as_str)
284 .unwrap_or("")
285 .to_string();
286 let name = content_block
287 .get("name")
288 .and_then(serde_json::Value::as_str)
289 .unwrap_or("")
290 .to_string();
291 BlockType::ToolUse { id, name }
292 }
293 other => BlockType::Other(other.to_string()),
294 }
295}
296
297#[cfg(feature = "json")]
298fn parse_block_delta(delta: &serde_json::Value) -> BlockDelta {
299 let Some(ty) = delta.get("type").and_then(serde_json::Value::as_str) else {
300 return BlockDelta::Other;
301 };
302 match ty {
303 "text_delta" => delta
304 .get("text")
305 .and_then(serde_json::Value::as_str)
306 .map(|s| BlockDelta::Text(s.to_string()))
307 .unwrap_or(BlockDelta::Other),
308 "thinking_delta" => delta
309 .get("thinking")
310 .and_then(serde_json::Value::as_str)
311 .map(|s| BlockDelta::Thinking(s.to_string()))
312 .unwrap_or(BlockDelta::Other),
313 "input_json_delta" => delta
314 .get("partial_json")
315 .and_then(serde_json::Value::as_str)
316 .map(|s| BlockDelta::InputJson(s.to_string()))
317 .unwrap_or(BlockDelta::Other),
318 _ => BlockDelta::Other,
319 }
320}
321
322#[cfg(all(feature = "json", feature = "async"))]
355pub async fn stream_query<F>(
356 claude: &Claude,
357 cmd: &crate::command::query::QueryCommand,
358 handler: F,
359) -> Result<CommandOutput>
360where
361 F: FnMut(StreamEvent),
362{
363 stream_query_impl(claude, cmd, handler, claude.timeout).await
364}
365
366#[cfg(all(feature = "json", feature = "async"))]
378async fn stream_query_impl<F>(
379 claude: &Claude,
380 cmd: &crate::command::query::QueryCommand,
381 mut handler: F,
382 timeout: Option<Duration>,
383) -> Result<CommandOutput>
384where
385 F: FnMut(StreamEvent),
386{
387 use crate::command::ClaudeCommand;
388
389 let args = cmd.args();
390
391 let mut command_args = Vec::new();
392 command_args.extend(claude.global_args.clone());
393 command_args.extend(args);
394
395 let span = tracing::debug_span!(
401 "claude.stream",
402 command = crate::exec::span_command(&command_args),
403 binary = %claude.binary.display(),
404 cwd = claude.working_dir.as_deref().map(|d| d.display().to_string()),
405 timeout_secs = timeout.map(|t| t.as_secs()),
406 outcome = tracing::field::Empty,
407 events = tracing::field::Empty,
408 exit_code = tracing::field::Empty,
409 duration_ms = tracing::field::Empty,
410 );
411 let _enter = span.enter();
412 let started = std::time::Instant::now();
413 let mut event_count: u64 = 0;
414
415 debug!(
416 binary = %claude.binary.display(),
417 args = ?command_args,
418 timeout = ?timeout,
419 "streaming claude command"
420 );
421
422 let mut cmd = Command::new(&claude.binary);
423 cmd.args(&command_args)
424 .stdout(std::process::Stdio::piped())
425 .stderr(std::process::Stdio::piped())
426 .stdin(std::process::Stdio::null())
427 .kill_on_drop(true);
430 crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
431 crate::exec::apply_process_group(&mut cmd, claude.process_group);
435
436 if let Some(ref dir) = claude.working_dir {
437 cmd.current_dir(dir);
438 }
439
440 let mut child = cmd.spawn().map_err(|e| Error::Io {
441 message: format!("failed to spawn claude: {e}"),
442 source: e,
443 working_dir: claude.working_dir.clone(),
444 })?;
445 let mut group =
446 crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
447
448 let stdout = child.stdout.take().expect("stdout was piped");
449 let mut stderr = child.stderr.take().expect("stderr was piped");
450
451 let mut reader = BufReader::new(stdout).lines();
452
453 let drain = drain_stderr(&mut stderr);
458 let mut counting_handler = |event: StreamEvent| {
461 event_count += 1;
462 handler(event);
463 };
464 let read_future = read_lines(
465 &mut reader,
466 &mut counting_handler,
467 claude.working_dir.clone(),
468 );
469 let combined = async {
470 let (line_result, stderr_str) = tokio::join!(read_future, drain);
471 (line_result, stderr_str)
472 };
473
474 let (line_result, stderr_str) = match timeout {
475 Some(d) => match tokio::time::timeout(d, combined).await {
476 Ok(pair) => pair,
477 Err(_) => {
478 crate::exec::kill_group_with_grace(&mut group, claude.kill_grace).await;
485 let _ = child.kill().await;
486 let drain_budget = Duration::from_millis(200);
487 let stderr_str = tokio::time::timeout(drain_budget, drain_stderr(&mut stderr))
488 .await
489 .unwrap_or_default();
490 if !stderr_str.is_empty() {
491 warn!(stderr = %stderr_str, "stderr from timed-out streaming process");
492 }
493 span.record("outcome", "timeout");
494 span.record("events", event_count);
495 span.record("duration_ms", started.elapsed().as_millis() as u64);
496 return Err(Error::Timeout {
497 timeout_seconds: d.as_secs(),
498 });
499 }
500 },
501 None => combined.await,
502 };
503
504 let stdout_diagnostics = match line_result {
507 Ok(diagnostics) => diagnostics.into_string(),
508 Err(e) => {
509 group.kill_now();
510 let _ = child.kill().await;
511 return Err(e);
512 }
513 };
514
515 let status = child.wait().await.map_err(|e| Error::Io {
516 message: "failed to wait for claude process".to_string(),
517 source: e,
518 working_dir: claude.working_dir.clone(),
519 })?;
520 group.disarm();
521
522 let exit_code = status.code().unwrap_or(-1);
523
524 span.record("events", event_count);
525 span.record("exit_code", exit_code);
526 span.record("duration_ms", started.elapsed().as_millis() as u64);
527
528 if !status.success() {
529 span.record("outcome", "failed");
530 return Err(Error::from_command_failure(
531 format!("{} {}", claude.binary.display(), command_args.join(" ")),
532 exit_code,
533 stdout_diagnostics,
534 stderr_str,
535 claude.working_dir.clone(),
536 ));
537 }
538
539 span.record("outcome", "completed");
540 Ok(CommandOutput {
541 stdout: String::new(), stderr: stderr_str,
543 exit_code,
544 success: true,
545 })
546}
547
548#[cfg(all(feature = "json", feature = "async"))]
549async fn drain_stderr(stderr: &mut ChildStderr) -> String {
550 let mut buf = Vec::new();
551 let _ = stderr.read_to_end(&mut buf).await;
552 String::from_utf8_lossy(&buf).into_owned()
553}
554
555#[cfg(all(feature = "json", feature = "async"))]
556async fn read_lines<F>(
557 reader: &mut tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
558 handler: &mut F,
559 working_dir: Option<std::path::PathBuf>,
560) -> Result<ParseFailureDiagnostics>
561where
562 F: FnMut(StreamEvent),
563{
564 let mut diagnostics = ParseFailureDiagnostics::default();
565 while let Some(line) = reader.next_line().await.map_err(|e| Error::Io {
566 message: "failed to read stdout line".to_string(),
567 source: e,
568 working_dir: working_dir.clone(),
569 })? {
570 if line.trim().is_empty() {
571 continue;
572 }
573 match serde_json::from_str::<StreamEvent>(&line) {
574 Ok(event) => handler(event),
575 Err(e) => {
576 debug!(line = %line, error = %e, "failed to parse stream event, skipping");
577 diagnostics.push(&line);
578 }
579 }
580 }
581
582 Ok(diagnostics)
583}
584
585#[cfg(all(feature = "sync", feature = "json"))]
623pub fn stream_query_sync<F>(
624 claude: &Claude,
625 cmd: &crate::command::query::QueryCommand,
626 mut handler: F,
627) -> Result<CommandOutput>
628where
629 F: FnMut(StreamEvent),
630{
631 use std::io::{BufRead as _, Read as _};
632 use std::process::{Command as StdCommand, Stdio};
633 use std::sync::mpsc;
634 use std::thread;
635 use std::time::Instant;
636
637 use crate::command::ClaudeCommand;
638
639 let args = cmd.args();
640 let mut command_args = Vec::new();
641 command_args.extend(claude.global_args.clone());
642 command_args.extend(args);
643
644 debug!(
645 binary = %claude.binary.display(),
646 args = ?command_args,
647 timeout = ?claude.timeout,
648 "streaming claude command (sync)"
649 );
650
651 let mut cmd_builder = StdCommand::new(&claude.binary);
652 cmd_builder
653 .args(&command_args)
654 .stdin(Stdio::null())
655 .stdout(Stdio::piped())
656 .stderr(Stdio::piped());
657 crate::exec::apply_child_environment(&mut cmd_builder, claude.clear_env, &claude.env);
658 crate::exec::apply_process_group_sync(&mut cmd_builder, claude.process_group);
662
663 if let Some(ref dir) = claude.working_dir {
664 cmd_builder.current_dir(dir);
665 }
666
667 let mut child = cmd_builder.spawn().map_err(|e| Error::Io {
668 message: format!("failed to spawn claude: {e}"),
669 source: e,
670 working_dir: claude.working_dir.clone(),
671 })?;
672 let mut group = crate::exec::arm_and_notify(
673 claude.process_group,
674 Some(child.id()),
675 claude.on_spawn.as_ref(),
676 );
677
678 let stdout = child.stdout.take().expect("stdout was piped");
679 let stderr = child.stderr.take().expect("stderr was piped");
680
681 let (tx, rx) = mpsc::channel::<StreamEvent>();
685 let reader_wd = claude.working_dir.clone();
686 let reader_thread = thread::spawn(move || -> Result<ParseFailureDiagnostics> {
687 let reader = std::io::BufReader::new(stdout);
688 let mut diagnostics = ParseFailureDiagnostics::default();
689 for line_res in reader.lines() {
690 let line = line_res.map_err(|e| Error::Io {
691 message: "failed to read stdout line".to_string(),
692 source: e,
693 working_dir: reader_wd.clone(),
694 })?;
695 if line.trim().is_empty() {
696 continue;
697 }
698 match serde_json::from_str::<StreamEvent>(&line) {
699 Ok(event) => {
700 if tx.send(event).is_err() {
701 return Ok(diagnostics);
703 }
704 }
705 Err(e) => {
706 debug!(line = %line, error = %e, "failed to parse stream event, skipping");
707 diagnostics.push(&line);
708 }
709 }
710 }
711 Ok(diagnostics)
712 });
713
714 let stderr_thread = thread::spawn(move || -> String {
715 let mut buf = Vec::new();
716 let mut stderr = stderr;
717 let _ = stderr.read_to_end(&mut buf);
718 String::from_utf8_lossy(&buf).into_owned()
719 });
720
721 let deadline = claude.timeout.map(|d| Instant::now() + d);
724 let mut timed_out = false;
725
726 loop {
727 let recv_result = match deadline {
728 Some(d) => {
729 let now = Instant::now();
730 if now >= d {
731 timed_out = true;
732 break;
733 }
734 rx.recv_timeout(d - now)
735 }
736 None => rx.recv().map_err(|_| mpsc::RecvTimeoutError::Disconnected),
737 };
738
739 match recv_result {
740 Ok(event) => handler(event),
741 Err(mpsc::RecvTimeoutError::Timeout) => {
742 timed_out = true;
743 break;
744 }
745 Err(mpsc::RecvTimeoutError::Disconnected) => break,
746 }
747 }
748
749 if timed_out {
750 crate::exec::kill_group_with_grace_sync(&mut group, claude.kill_grace);
754 let _ = child.kill();
755 let _ = child.wait();
756 let budget = Duration::from_millis(200);
763 let stderr_str = join_with_budget(stderr_thread, budget).unwrap_or_default();
764 let _ = join_with_budget(reader_thread, budget);
765 if !stderr_str.is_empty() {
766 warn!(stderr = %stderr_str, "stderr from timed-out streaming process");
767 }
768 return Err(Error::Timeout {
769 timeout_seconds: claude.timeout.map(|d| d.as_secs()).unwrap_or_default(),
770 });
771 }
772
773 let reader_result = reader_thread
775 .join()
776 .unwrap_or_else(|_| Ok(ParseFailureDiagnostics::default()));
777 let stdout_diagnostics = match reader_result {
778 Ok(diagnostics) => diagnostics.into_string(),
779 Err(e) => {
780 group.kill_now();
781 let _ = child.kill();
782 let _ = child.wait();
783 let _ = stderr_thread.join();
784 return Err(e);
785 }
786 };
787
788 let status = child.wait().map_err(|e| Error::Io {
789 message: "failed to wait for claude process".to_string(),
790 source: e,
791 working_dir: claude.working_dir.clone(),
792 })?;
793 group.disarm();
794 let stderr_str = stderr_thread.join().unwrap_or_default();
795 let exit_code = status.code().unwrap_or(-1);
796
797 if !status.success() {
798 return Err(Error::from_command_failure(
799 format!("{} {}", claude.binary.display(), command_args.join(" ")),
800 exit_code,
801 stdout_diagnostics,
802 stderr_str,
803 claude.working_dir.clone(),
804 ));
805 }
806
807 Ok(CommandOutput {
808 stdout: String::new(),
809 stderr: stderr_str,
810 exit_code,
811 success: true,
812 })
813}
814
815#[cfg(all(feature = "sync", feature = "json"))]
820fn join_with_budget<T: Send + 'static>(
821 handle: std::thread::JoinHandle<T>,
822 budget: Duration,
823) -> Option<T> {
824 use std::sync::mpsc;
825 use std::thread;
826
827 let (tx, rx) = mpsc::channel::<T>();
828 thread::spawn(move || {
829 if let Ok(v) = handle.join() {
830 let _ = tx.send(v);
831 }
832 });
833 rx.recv_timeout(budget).ok()
834}
835
836#[cfg(all(test, feature = "json"))]
837mod tests {
838 use super::*;
839 use serde_json::json;
840
841 fn parse(v: serde_json::Value) -> StreamEvent {
842 serde_json::from_value(v).expect("valid StreamEvent")
843 }
844
845 fn wrap(inner: serde_json::Value) -> StreamEvent {
846 parse(json!({
847 "type": "stream_event",
848 "event": inner,
849 "session_id": "sess-1",
850 "parent_tool_use_id": null,
851 "uuid": "11111111-1111-1111-1111-111111111111"
852 }))
853 }
854
855 #[test]
856 fn parse_failure_diagnostics_are_bounded_and_keep_recent_lines() {
857 let mut diagnostics = ParseFailureDiagnostics::default();
858 diagnostics.push(&"x".repeat(STREAM_DIAGNOSTIC_MAX_BYTES));
859 diagnostics.push("Not authenticated. Run `claude login`.");
860
861 let output = diagnostics.into_string();
862 assert!(output.len() <= STREAM_DIAGNOSTIC_MAX_BYTES);
863 assert_eq!(output, "Not authenticated. Run `claude login`.");
864 }
865
866 #[test]
867 fn partial_message_text_block_lifecycle() {
868 let start = wrap(json!({
869 "type": "content_block_start",
870 "index": 0,
871 "content_block": { "type": "text", "text": "" }
872 }));
873 assert_eq!(
874 start.partial_message(),
875 Some(PartialMessageEvent::BlockStart {
876 index: 0,
877 block_type: BlockType::Text,
878 })
879 );
880
881 let delta = wrap(json!({
882 "type": "content_block_delta",
883 "index": 0,
884 "delta": { "type": "text_delta", "text": "Hello" }
885 }));
886 assert_eq!(
887 delta.partial_message(),
888 Some(PartialMessageEvent::BlockDelta {
889 index: 0,
890 delta: BlockDelta::Text("Hello".into()),
891 })
892 );
893
894 let stop = wrap(json!({ "type": "content_block_stop", "index": 0 }));
895 assert_eq!(
896 stop.partial_message(),
897 Some(PartialMessageEvent::BlockStop { index: 0 })
898 );
899 }
900
901 #[test]
902 fn partial_message_thinking_block_lifecycle() {
903 let start = wrap(json!({
904 "type": "content_block_start",
905 "index": 1,
906 "content_block": { "type": "thinking", "thinking": "", "signature": "" }
907 }));
908 assert_eq!(
909 start.partial_message(),
910 Some(PartialMessageEvent::BlockStart {
911 index: 1,
912 block_type: BlockType::Thinking,
913 })
914 );
915
916 let delta = wrap(json!({
917 "type": "content_block_delta",
918 "index": 1,
919 "delta": { "type": "thinking_delta", "thinking": "weighing options" }
920 }));
921 assert_eq!(
922 delta.partial_message(),
923 Some(PartialMessageEvent::BlockDelta {
924 index: 1,
925 delta: BlockDelta::Thinking("weighing options".into()),
926 })
927 );
928
929 let stop = wrap(json!({ "type": "content_block_stop", "index": 1 }));
930 assert_eq!(
931 stop.partial_message(),
932 Some(PartialMessageEvent::BlockStop { index: 1 })
933 );
934 }
935
936 #[test]
937 fn partial_message_tool_use_block_carries_id_and_name() {
938 let start = wrap(json!({
939 "type": "content_block_start",
940 "index": 2,
941 "content_block": {
942 "type": "tool_use",
943 "id": "toolu_abc",
944 "name": "Bash",
945 "input": {}
946 }
947 }));
948 assert_eq!(
949 start.partial_message(),
950 Some(PartialMessageEvent::BlockStart {
951 index: 2,
952 block_type: BlockType::ToolUse {
953 id: "toolu_abc".into(),
954 name: "Bash".into(),
955 },
956 })
957 );
958
959 let delta = wrap(json!({
960 "type": "content_block_delta",
961 "index": 2,
962 "delta": { "type": "input_json_delta", "partial_json": "{\"cmd\":" }
963 }));
964 assert_eq!(
965 delta.partial_message(),
966 Some(PartialMessageEvent::BlockDelta {
967 index: 2,
968 delta: BlockDelta::InputJson("{\"cmd\":".into()),
969 })
970 );
971 }
972
973 #[test]
974 fn partial_message_unknown_kinds_fall_through_to_other() {
975 let unknown_block = wrap(json!({
976 "type": "content_block_start",
977 "index": 3,
978 "content_block": { "type": "redacted_thinking", "data": "..." }
979 }));
980 assert_eq!(
981 unknown_block.partial_message(),
982 Some(PartialMessageEvent::BlockStart {
983 index: 3,
984 block_type: BlockType::Other("redacted_thinking".into()),
985 })
986 );
987
988 let unknown_delta = wrap(json!({
989 "type": "content_block_delta",
990 "index": 3,
991 "delta": { "type": "signature_delta", "signature": "sig" }
992 }));
993 assert_eq!(
994 unknown_delta.partial_message(),
995 Some(PartialMessageEvent::BlockDelta {
996 index: 3,
997 delta: BlockDelta::Other,
998 })
999 );
1000 }
1001
1002 #[test]
1003 fn partial_message_returns_none_for_non_partial_events() {
1004 let result = parse(json!({
1005 "type": "result",
1006 "result": "done",
1007 "session_id": "sess-1",
1008 "total_cost_usd": 0.01
1009 }));
1010 assert!(result.partial_message().is_none());
1011
1012 let assistant = parse(json!({
1013 "type": "assistant",
1014 "message": { "role": "assistant", "content": [] },
1015 "session_id": "sess-1"
1016 }));
1017 assert!(assistant.partial_message().is_none());
1018
1019 let message_start = wrap(json!({
1020 "type": "message_start",
1021 "message": { "id": "msg_1", "role": "assistant", "content": [] }
1022 }));
1023 assert!(message_start.partial_message().is_none());
1024 }
1025
1026 #[test]
1027 fn partial_message_accepts_unwrapped_event() {
1028 let raw = parse(json!({
1029 "type": "content_block_delta",
1030 "index": 0,
1031 "delta": { "type": "text_delta", "text": "hi" }
1032 }));
1033 assert_eq!(
1034 raw.partial_message(),
1035 Some(PartialMessageEvent::BlockDelta {
1036 index: 0,
1037 delta: BlockDelta::Text("hi".into()),
1038 })
1039 );
1040 }
1041}