1pub const STREAMING_VIRTUAL_FIELDS: &[&str] = &[
34 "chunks",
35 "chunks.length",
36 "stream_content",
37 "stream_complete",
38 "no_chunks_after_done",
39 "tool_calls",
40 "finish_reason",
41];
42
43const STREAMING_VIRTUAL_ROOTS: &[&str] = &["tool_calls", "finish_reason"];
53
54pub fn is_streaming_virtual_field(field: &str) -> bool {
62 if STREAMING_VIRTUAL_FIELDS.contains(&field) {
63 return true;
64 }
65 for root in STREAMING_VIRTUAL_ROOTS {
67 if field.len() > root.len() && field.starts_with(root) {
68 let rest = &field[root.len()..];
69 if rest.starts_with('[') || rest.starts_with('.') {
70 return true;
71 }
72 }
73 }
74 false
75}
76
77fn split_streaming_deep_path(field: &str) -> Option<(&str, &str)> {
83 for root in STREAMING_VIRTUAL_ROOTS {
84 if field.len() > root.len() && field.starts_with(root) {
85 let rest = &field[root.len()..];
86 if rest.starts_with('[') || rest.starts_with('.') {
87 return Some((root, rest));
88 }
89 }
90 }
91 None
92}
93
94const STREAMING_ONLY_AUTO_DETECT_FIELDS: &[&str] = &[
101 "chunks",
102 "chunks.length",
103 "stream_content",
104 "stream_complete",
105 "no_chunks_after_done",
106];
107
108pub fn resolve_is_streaming(fixture: &crate::fixture::Fixture, call_streaming: Option<bool>) -> bool {
120 if let Some(forced) = call_streaming {
121 return forced;
122 }
123 fixture.is_streaming_mock()
124 || fixture.assertions.iter().any(|a| {
125 a.field
126 .as_deref()
127 .is_some_and(|f| !f.is_empty() && STREAMING_ONLY_AUTO_DETECT_FIELDS.contains(&f))
128 })
129}
130
131pub struct StreamingFieldResolver;
133
134impl StreamingFieldResolver {
135 pub fn accessor(field: &str, lang: &str, chunks_var: &str) -> Option<String> {
141 match field {
142 "chunks" => Some(match lang {
143 "zig" => format!("{chunks_var}.items"),
145 "php" => format!("${chunks_var}"),
148 _ => chunks_var.to_string(),
149 }),
150
151 "chunks.length" => Some(match lang {
152 "rust" => format!("{chunks_var}.len()"),
153 "go" => format!("len({chunks_var})"),
154 "python" => format!("len({chunks_var})"),
155 "php" => format!("count(${chunks_var})"),
156 "elixir" => format!("length({chunks_var})"),
157 "kotlin" => format!("{chunks_var}.size"),
159 "zig" => format!("{chunks_var}.items.len"),
161 _ => format!("{chunks_var}.length"),
163 }),
164
165 "stream_content" => Some(match lang {
166 "rust" => {
167 format!(
168 "{chunks_var}.iter().map(|c| c.choices.first().and_then(|ch| ch.delta.content.as_deref()).unwrap_or(\"\")).collect::<String>()"
169 )
170 }
171 "go" => {
172 format!(
174 "func() string {{ var s string; for _, c := range {chunks_var} {{ if len(c.Choices) > 0 && c.Choices[0].Delta.Content != nil {{ s += *c.Choices[0].Delta.Content }} }}; return s }}()"
175 )
176 }
177 "java" => {
178 format!(
179 "{chunks_var}.stream().map(c -> c.choices().stream().findFirst().map(ch -> ch.delta().content() != null ? ch.delta().content() : \"\").orElse(\"\")).collect(java.util.stream.Collectors.joining())"
180 )
181 }
182 "php" => {
183 format!("implode('', array_map(fn($c) => $c->choices[0]->delta->content ?? '', ${chunks_var}))")
184 }
185 "kotlin" => {
186 format!(
189 "{chunks_var}.joinToString(\"\") {{ it.choices()?.firstOrNull()?.delta()?.content() ?: \"\" }}"
190 )
191 }
192 "elixir" => {
193 format!(
197 "{chunks_var} |> Enum.map(fn c -> (Enum.at(c.choices, 0) || %{{}}) |> Map.get(:delta, %{{}}) |> Map.get(:content, \"\") end) |> Enum.join(\"\")"
198 )
199 }
200 "python" => {
201 format!("\"\".join(c.choices[0].delta.content or \"\" for c in {chunks_var} if c.choices)")
202 }
203 "zig" => {
204 format!("{chunks_var}_content.items")
207 }
208 _ => {
210 format!("{chunks_var}.map((c: any) => c.choices?.[0]?.delta?.content ?? '').join('')")
211 }
212 }),
213
214 "stream_complete" => Some(match lang {
215 "rust" => {
216 format!(
217 "{chunks_var}.last().and_then(|c| c.choices.first()).and_then(|ch| ch.finish_reason.as_ref()).is_some()"
218 )
219 }
220 "go" => {
221 format!(
222 "func() bool {{ if len({chunks_var}) == 0 {{ return false }}; last := {chunks_var}[len({chunks_var})-1]; return len(last.Choices) > 0 && last.Choices[0].FinishReason != nil }}()"
223 )
224 }
225 "java" => {
226 format!(
227 "!{chunks_var}.isEmpty() && {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().flatMap(ch -> java.util.Optional.ofNullable(ch.finishReason())).isPresent()"
228 )
229 }
230 "php" => {
231 format!("!empty(${chunks_var}) && isset(end(${chunks_var})->choices[0]->finishReason)")
235 }
236 "kotlin" => {
237 format!(
239 "{chunks_var}.isNotEmpty() && {chunks_var}.last().choices()?.firstOrNull()?.finishReason() != null"
240 )
241 }
242 "python" => {
243 format!("bool({chunks_var}) and {chunks_var}[-1].choices[0].finish_reason is not None")
244 }
245 "elixir" => {
246 format!("Enum.at(List.last({chunks_var}).choices, 0).finish_reason != nil")
247 }
248 "zig" => {
251 format!("{chunks_var}.items.len > 0")
252 }
253 _ => {
255 format!(
256 "{chunks_var}.length > 0 && {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason != null"
257 )
258 }
259 }),
260
261 "no_chunks_after_done" => Some(match lang {
265 "rust" => "true".to_string(),
266 "go" => "true".to_string(),
267 "java" => "true".to_string(),
268 "php" => "true".to_string(),
269 _ => "true".to_string(),
270 }),
271
272 "tool_calls" => Some(match lang {
273 "rust" => {
274 format!(
275 "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten())).collect::<Vec<_>>()"
276 )
277 }
278 "go" => {
279 format!(
283 "func() []pkg.StreamToolCall {{ var tc []pkg.StreamToolCall; for _, c := range {chunks_var} {{ for _, ch := range c.Choices {{ tc = append(tc, ch.Delta.ToolCalls...) }} }}; return tc }}()"
284 )
285 }
286 "java" => {
287 format!(
288 "{chunks_var}.stream().flatMap(c -> c.choices().stream()).flatMap(ch -> ch.delta().toolCalls() != null ? ch.delta().toolCalls().stream() : java.util.stream.Stream.empty()).toList()"
289 )
290 }
291 "php" => {
292 format!(
295 "array_merge(...array_map(fn($c) => $c->choices[0]->delta->toolCalls ?? [], ${chunks_var}))"
296 )
297 }
298 "kotlin" => {
299 format!(
301 "{chunks_var}.flatMap {{ c -> c.choices()?.flatMap {{ ch -> ch.delta()?.toolCalls() ?: emptyList() }} ?: emptyList() }}"
302 )
303 }
304 "python" => {
305 format!(
306 "[t for c in {chunks_var} for ch in (c.choices or []) for t in (ch.delta.tool_calls or [])]"
307 )
308 }
309 "elixir" => {
310 format!(
311 "{chunks_var} |> Enum.flat_map(fn c -> (List.first(c.choices) || %{{}}).delta |> Map.get(:tool_calls, []) end)"
312 )
313 }
314 "zig" => {
316 format!("{chunks_var}.items")
317 }
318 _ => {
319 format!("{chunks_var}.flatMap((c: any) => c.choices?.[0]?.delta?.toolCalls ?? [])")
320 }
321 }),
322
323 "finish_reason" => Some(match lang {
324 "rust" => {
325 format!(
328 "{chunks_var}.last().and_then(|c| c.choices.first()).and_then(|ch| ch.finish_reason.as_ref()).map(|v| v.to_string()).unwrap_or_default()"
329 )
330 }
331 "go" => {
332 format!(
335 "func() string {{ if len({chunks_var}) == 0 {{ return \"\" }}; last := {chunks_var}[len({chunks_var})-1]; if len(last.Choices) > 0 && last.Choices[0].FinishReason != nil {{ return string(*last.Choices[0].FinishReason) }}; return \"\" }}()"
336 )
337 }
338 "java" => {
339 format!(
343 "({chunks_var}.isEmpty() ? null : {chunks_var}.get({chunks_var}.size()-1).choices().stream().findFirst().map(ch -> ch.finishReason() == null ? null : ch.finishReason().getValue()).orElse(null))"
344 )
345 }
346 "php" => {
347 format!("(!empty(${chunks_var}) ? (end(${chunks_var})->choices[0]->finishReason ?? null) : null)")
350 }
351 "kotlin" => {
352 format!(
355 "(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().choices()?.firstOrNull()?.finishReason()?.getValue())"
356 )
357 }
358 "python" => {
359 format!(
363 "(str({chunks_var}[-1].choices[0].finish_reason) if {chunks_var} and {chunks_var}[-1].choices else None)"
364 )
365 }
366 "elixir" => {
367 format!("Enum.at(List.last({chunks_var}).choices, 0).finish_reason")
368 }
369 "zig" => {
372 format!(
373 "(blk: {{ if ({chunks_var}.items.len == 0) break :blk \"\"; var _lcp = std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, {chunks_var}.items[{chunks_var}.items.len - 1], .{{}}) catch break :blk \"\"; defer _lcp.deinit(); if (_lcp.value.object.get(\"choices\")) |_lchs| if (_lchs.array.items.len > 0) if (_lchs.array.items[0].object.get(\"finish_reason\")) |_fr| if (_fr == .string) break :blk _fr.string; break :blk \"\"; }})"
374 )
375 }
376 _ => {
377 format!(
378 "{chunks_var}.length > 0 ? {chunks_var}[{chunks_var}.length - 1].choices?.[0]?.finishReason : undefined"
379 )
380 }
381 }),
382
383 "usage" => Some(match lang {
388 "python" => {
389 format!("({chunks_var}[-1].usage if {chunks_var} else None)")
393 }
394 "rust" => {
395 format!("{chunks_var}.last().and_then(|c| c.usage.as_ref())")
396 }
397 "go" => {
398 format!(
399 "func() interface{{}} {{ if len({chunks_var}) == 0 {{ return nil }}; return {chunks_var}[len({chunks_var})-1].Usage }}()"
400 )
401 }
402 "java" => {
403 format!("({chunks_var}.isEmpty() ? null : {chunks_var}.get({chunks_var}.size()-1).usage())")
404 }
405 "kotlin" => {
406 format!("(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().usage())")
407 }
408 "php" => {
409 format!("(!empty(${chunks_var}) ? end(${chunks_var})->usage ?? null : null)")
410 }
411 "elixir" => {
412 format!("(if length({chunks_var}) > 0, do: List.last({chunks_var}).usage, else: nil)")
413 }
414 _ => {
415 format!("({chunks_var}.length > 0 ? {chunks_var}[{chunks_var}.length - 1].usage : undefined)")
416 }
417 }),
418
419 _ => {
420 if let Some((root, tail)) = split_streaming_deep_path(field) {
424 if lang == "rust" && root == "tool_calls" {
428 return Some(render_rust_tool_calls_deep(chunks_var, tail));
429 }
430 if lang == "zig" && root == "tool_calls" {
437 return None;
438 }
439 let root_expr = Self::accessor(root, lang, chunks_var)?;
440 Some(render_deep_tail(&root_expr, tail, lang))
441 } else {
442 None
443 }
444 }
445 }
446 }
447
448 pub fn collect_snippet(lang: &str, stream_var: &str, chunks_var: &str) -> Option<String> {
454 match lang {
455 "rust" => Some(format!(
456 "let {chunks_var}: Vec<_> = tokio_stream::StreamExt::collect::<Vec<_>>({stream_var}).await\n .into_iter()\n .map(|r| r.expect(\"stream item failed\"))\n .collect();"
457 )),
458 "go" => Some(format!(
459 "var {chunks_var} []pkg.ChatCompletionChunk\n\tfor chunk := range {stream_var} {{\n\t\t{chunks_var} = append({chunks_var}, chunk)\n\t}}"
460 )),
461 "java" => Some(format!(
462 "var {chunks_var} = new java.util.ArrayList<ChatCompletionChunk>();\n var _it = {stream_var};\n while (_it.hasNext()) {{ {chunks_var}.add(_it.next()); }}"
463 )),
464 "php" => Some(format!(
471 "${chunks_var} = is_string(${stream_var}) ? (json_decode(${stream_var}) ?: []) : iterator_to_array(${stream_var});"
472 )),
473 "python" => Some(format!(
474 "{chunks_var} = []\n async for chunk in {stream_var}:\n {chunks_var}.append(chunk)"
475 )),
476 "kotlin" => {
477 Some(format!("val {chunks_var} = {stream_var}.asSequence().toList()"))
480 }
481 "elixir" => Some(format!("{chunks_var} = Enum.to_list({stream_var})")),
482 "node" | "wasm" | "typescript" => Some(format!(
483 "const {chunks_var}: any[] = [];\n for await (const _chunk of {stream_var}) {{ {chunks_var}.push(_chunk); }}"
484 )),
485 "swift" => {
486 Some(format!(
491 "var {chunks_var}: [ChatCompletionChunk] = []\n for try await _chunk in {stream_var} {{ {chunks_var}.append(_chunk) }}"
492 ))
493 }
494 "zig" => Some(Self::collect_snippet_zig(stream_var, chunks_var, "module", "ffi")),
495 _ => None,
496 }
497 }
498
499 pub fn collect_snippet_zig(stream_var: &str, chunks_var: &str, module_name: &str, ffi_prefix: &str) -> String {
501 let stream_next = format!("{ffi_prefix}_default_client_chat_stream_next");
502 let chunk_to_json = format!("{ffi_prefix}_chat_completion_chunk_to_json");
503 let chunk_free = format!("{ffi_prefix}_chat_completion_chunk_free");
504 let free_string = format!("{ffi_prefix}_free_string");
505
506 format!(
513 concat!(
514 "var {chunks_var}: std.ArrayList([]u8) = .empty;
515",
516 " defer {{
517",
518 " for ({chunks_var}.items) |_cj| std.heap.c_allocator.free(_cj);
519",
520 " {chunks_var}.deinit(std.heap.c_allocator);
521",
522 " }}
523",
524 " var {chunks_var}_content: std.ArrayList(u8) = .empty;
525",
526 " defer {chunks_var}_content.deinit(std.heap.c_allocator);
527",
528 " while (true) {{
529",
530 " const _nc = {module_name}.c.{stream_next}({stream_var});
531",
532 " if (_nc == null) break;
533",
534 " const _np = {module_name}.c.{chunk_to_json}(_nc);
535",
536 " {module_name}.c.{chunk_free}(_nc);
537",
538 " if (_np == null) continue;
539",
540 " const _ns = std.mem.span(_np);
541",
542 " const _nj = try std.heap.c_allocator.dupe(u8, _ns);
543",
544 " {module_name}.c.{free_string}(_np);
545",
546 " if (std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, _nj, .{{}})) |_cp| {{
547",
548 " defer _cp.deinit();
549",
550 " if (_cp.value.object.get(\"choices\")) |_chs|
551",
552 " if (_chs.array.items.len > 0)
553",
554 " if (_chs.array.items[0].object.get(\"delta\")) |_dl|
555",
556 " if (_dl.object.get(\"content\")) |_ct|
557",
558 " if (_ct == .string) try {chunks_var}_content.appendSlice(std.heap.c_allocator, _ct.string);
559",
560 " }} else |_| {{}}
561",
562 " try {chunks_var}.append(std.heap.c_allocator, _nj);
563",
564 " }}"
565 ),
566 chunks_var = chunks_var,
567 stream_var = stream_var,
568 module_name = module_name,
569 stream_next = stream_next,
570 chunk_to_json = chunk_to_json,
571 chunk_free = chunk_free,
572 free_string = free_string,
573 )
574 }
575}
576
577fn render_rust_tool_calls_deep(chunks_var: &str, tail: &str) -> String {
581 let segs = parse_tail(tail);
582 let idx = segs.iter().find_map(|s| match s {
584 TailSeg::Index(n) => Some(*n),
585 _ => None,
586 });
587 let field_segs: Vec<&str> = segs
588 .iter()
589 .filter_map(|s| match s {
590 TailSeg::Field(f) => Some(f.as_str()),
591 _ => None,
592 })
593 .collect();
594
595 let base = format!(
596 "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten()))"
597 );
598 let with_nth = match idx {
599 Some(n) => format!("{base}.nth({n})"),
600 None => base,
601 };
602
603 let mut expr = with_nth;
606 for (i, f) in field_segs.iter().enumerate() {
607 let is_leaf = i == field_segs.len() - 1;
608 if is_leaf {
609 expr = format!("{expr}.and_then(|x| x.{f}.as_deref())");
610 } else {
611 expr = format!("{expr}.and_then(|x| x.{f}.as_ref())");
612 }
613 }
614 format!("{expr}.unwrap_or(\"\")")
615}
616
617#[derive(Debug, PartialEq)]
622enum TailSeg {
623 Index(usize),
624 Field(String),
625}
626
627fn parse_tail(tail: &str) -> Vec<TailSeg> {
628 let mut segs = Vec::new();
629 let mut rest = tail;
630 while !rest.is_empty() {
631 if let Some(inner) = rest.strip_prefix('[') {
632 if let Some(close) = inner.find(']') {
634 let idx_str = &inner[..close];
635 if let Ok(idx) = idx_str.parse::<usize>() {
636 segs.push(TailSeg::Index(idx));
637 }
638 rest = &inner[close + 1..];
639 } else {
640 break;
641 }
642 } else if let Some(inner) = rest.strip_prefix('.') {
643 let end = inner.find(['.', '[']).unwrap_or(inner.len());
645 segs.push(TailSeg::Field(inner[..end].to_string()));
646 rest = &inner[end..];
647 } else {
648 break;
649 }
650 }
651 segs
652}
653
654fn render_deep_tail(root_expr: &str, tail: &str, lang: &str) -> String {
657 use heck::{ToLowerCamelCase, ToPascalCase};
658
659 let segs = parse_tail(tail);
660 let mut out = root_expr.to_string();
661
662 for seg in &segs {
663 match (seg, lang) {
664 (TailSeg::Index(n), "rust") => {
665 out = format!("({out})[{n}]");
666 }
667 (TailSeg::Index(n), "java") => {
668 out = format!("({out}).get({n})");
669 }
670 (TailSeg::Index(n), "kotlin") => {
671 if *n == 0 {
672 out = format!("({out}).first()");
673 } else {
674 out = format!("({out}).get({n})");
675 }
676 }
677 (TailSeg::Index(n), "elixir") => {
678 out = format!("Enum.at({out}, {n})");
679 }
680 (TailSeg::Index(n), "zig") => {
681 out = format!("({out}).items[{n}]");
682 }
683 (TailSeg::Index(n), "php") => {
684 out = format!("({out})[{n}]");
685 }
686 (TailSeg::Index(n), _) => {
687 out = format!("({out})[{n}]");
689 }
690 (TailSeg::Field(f), "rust") => {
691 use heck::ToSnakeCase;
692 out.push('.');
693 out.push_str(&f.to_snake_case());
694 }
695 (TailSeg::Field(f), "go") => {
696 use alef_codegen::naming::to_go_name;
697 out.push('.');
698 out.push_str(&to_go_name(f));
699 }
700 (TailSeg::Field(f), "java") => {
701 out.push('.');
702 out.push_str(&f.to_lower_camel_case());
703 out.push_str("()");
704 }
705 (TailSeg::Field(f), "kotlin") => {
706 out.push_str("?.");
712 out.push_str(&f.to_lower_camel_case());
713 out.push_str("()");
714 }
715 (TailSeg::Field(f), "csharp") => {
716 out.push('.');
717 out.push_str(&f.to_pascal_case());
718 }
719 (TailSeg::Field(f), "php") => {
720 out.push_str("->");
725 out.push_str(f);
726 }
727 (TailSeg::Field(f), "elixir") => {
728 out.push('.');
729 out.push_str(f);
730 }
731 (TailSeg::Field(f), "zig") => {
732 out.push('.');
733 out.push_str(f);
734 }
735 (TailSeg::Field(f), "python") | (TailSeg::Field(f), "ruby") => {
736 out.push('.');
737 out.push_str(f);
738 }
739 (TailSeg::Field(f), _) => {
741 out.push('.');
742 out.push_str(&f.to_lower_camel_case());
743 }
744 }
745 }
746
747 out
748}
749
750#[cfg(test)]
751mod tests {
752 use super::*;
753
754 #[test]
755 fn is_streaming_virtual_field_recognizes_all_fields() {
756 for field in STREAMING_VIRTUAL_FIELDS {
757 assert!(
758 is_streaming_virtual_field(field),
759 "field '{field}' not recognized as streaming virtual"
760 );
761 }
762 }
763
764 #[test]
765 fn is_streaming_virtual_field_rejects_real_fields() {
766 assert!(!is_streaming_virtual_field("content"));
767 assert!(!is_streaming_virtual_field("choices"));
768 assert!(!is_streaming_virtual_field("model"));
769 assert!(!is_streaming_virtual_field(""));
770 }
771
772 #[test]
773 fn is_streaming_virtual_field_rejects_non_root_paths_with_matching_tail() {
774 assert!(!is_streaming_virtual_field("choices[0].finish_reason"));
779 assert!(!is_streaming_virtual_field("choices[0].message.content"));
780 assert!(!is_streaming_virtual_field("data[0].embedding"));
781 }
782
783 #[test]
784 fn is_streaming_virtual_field_does_not_match_usage() {
785 assert!(!is_streaming_virtual_field("usage"));
789 assert!(!is_streaming_virtual_field("usage.total_tokens"));
790 assert!(!is_streaming_virtual_field("usage.prompt_tokens"));
791 }
792
793 #[test]
794 fn accessor_chunks_returns_var_name() {
795 assert_eq!(
796 StreamingFieldResolver::accessor("chunks", "rust", "chunks"),
797 Some("chunks".to_string())
798 );
799 assert_eq!(
800 StreamingFieldResolver::accessor("chunks", "node", "chunks"),
801 Some("chunks".to_string())
802 );
803 }
804
805 #[test]
806 fn accessor_chunks_length_uses_language_idiom() {
807 let rust = StreamingFieldResolver::accessor("chunks.length", "rust", "chunks").unwrap();
808 assert!(rust.contains(".len()"), "rust: {rust}");
809
810 let go = StreamingFieldResolver::accessor("chunks.length", "go", "chunks").unwrap();
811 assert!(go.starts_with("len("), "go: {go}");
812
813 let node = StreamingFieldResolver::accessor("chunks.length", "node", "chunks").unwrap();
814 assert!(node.contains(".length"), "node: {node}");
815
816 let php = StreamingFieldResolver::accessor("chunks.length", "php", "chunks").unwrap();
817 assert!(php.starts_with("count("), "php: {php}");
818 }
819
820 #[test]
821 fn accessor_chunks_length_zig_uses_items_len() {
822 let zig = StreamingFieldResolver::accessor("chunks.length", "zig", "chunks").unwrap();
823 assert_eq!(zig, "chunks.items.len", "zig chunks.length: {zig}");
824 }
825
826 #[test]
827 fn accessor_stream_content_zig_uses_content_items() {
828 let zig = StreamingFieldResolver::accessor("stream_content", "zig", "chunks").unwrap();
829 assert_eq!(zig, "chunks_content.items", "zig stream_content: {zig}");
830 }
831
832 #[test]
833 fn collect_snippet_zig_drains_via_ffi() {
834 let snip = StreamingFieldResolver::collect_snippet("zig", "_stream_handle", "chunks").unwrap();
835 assert!(snip.contains("std.ArrayList([]u8)"), "zig collect: {snip}");
836 assert!(snip.contains("chat_stream_next(_stream_handle)"), "zig collect: {snip}");
837 assert!(snip.contains("chunks_content"), "zig collect: {snip}");
838 assert!(
839 snip.contains("chunks.append(std.heap.c_allocator"),
840 "zig collect: {snip}"
841 );
842 assert!(snip.contains(".empty;"), "zig collect (Zig 0.16 unmanaged): {snip}");
843 }
844
845 #[test]
846 fn accessor_stream_content_rust_uses_iterator() {
847 let expr = StreamingFieldResolver::accessor("stream_content", "rust", "chunks").unwrap();
848 assert!(expr.contains(".collect::<String>()"), "rust stream_content: {expr}");
849 }
850
851 #[test]
852 fn accessor_no_chunks_after_done_returns_true() {
853 for lang in ["rust", "go", "java", "php", "node", "wasm", "elixir"] {
854 let expr = StreamingFieldResolver::accessor("no_chunks_after_done", lang, "chunks").unwrap();
855 assert_eq!(expr, "true", "lang {lang}: expected 'true', got '{expr}'");
856 }
857 }
858
859 #[test]
860 fn accessor_elixir_chunks_length_uses_length_function() {
861 let expr = StreamingFieldResolver::accessor("chunks.length", "elixir", "chunks").unwrap();
862 assert_eq!(expr, "length(chunks)", "elixir chunks.length: {expr}");
863 }
864
865 #[test]
866 fn accessor_elixir_stream_content_uses_pipe() {
867 let expr = StreamingFieldResolver::accessor("stream_content", "elixir", "chunks").unwrap();
868 assert!(expr.contains("|> Enum.join"), "elixir stream_content: {expr}");
869 assert!(expr.contains("|> Enum.map"), "elixir stream_content: {expr}");
870 assert!(
872 !expr.contains("choices[0]"),
873 "elixir stream_content must not use bracket access on list: {expr}"
874 );
875 assert!(
876 expr.contains("Enum.at("),
877 "elixir stream_content must use Enum.at for list index: {expr}"
878 );
879 }
880
881 #[test]
882 fn accessor_elixir_stream_complete_uses_list_last() {
883 let expr = StreamingFieldResolver::accessor("stream_complete", "elixir", "chunks").unwrap();
884 assert!(expr.contains("List.last(chunks)"), "elixir stream_complete: {expr}");
885 assert!(expr.contains("finish_reason != nil"), "elixir stream_complete: {expr}");
886 assert!(
888 !expr.contains("choices[0]"),
889 "elixir stream_complete must not use bracket access on list: {expr}"
890 );
891 assert!(
892 expr.contains("Enum.at("),
893 "elixir stream_complete must use Enum.at for list index: {expr}"
894 );
895 }
896
897 #[test]
898 fn accessor_elixir_finish_reason_uses_list_last() {
899 let expr = StreamingFieldResolver::accessor("finish_reason", "elixir", "chunks").unwrap();
900 assert!(expr.contains("List.last(chunks)"), "elixir finish_reason: {expr}");
901 assert!(expr.contains("finish_reason"), "elixir finish_reason: {expr}");
902 assert!(
904 !expr.contains("choices[0]"),
905 "elixir finish_reason must not use bracket access on list: {expr}"
906 );
907 assert!(
908 expr.contains("Enum.at("),
909 "elixir finish_reason must use Enum.at for list index: {expr}"
910 );
911 }
912
913 #[test]
914 fn collect_snippet_elixir_uses_enum_to_list() {
915 let snip = StreamingFieldResolver::collect_snippet("elixir", "result", "chunks").unwrap();
916 assert!(snip.contains("Enum.to_list(result)"), "elixir: {snip}");
917 assert!(snip.contains("chunks ="), "elixir: {snip}");
918 }
919
920 #[test]
921 fn collect_snippet_rust_uses_tokio_stream() {
922 let snip = StreamingFieldResolver::collect_snippet("rust", "result", "chunks").unwrap();
923 assert!(snip.contains("tokio_stream::StreamExt::collect"), "rust: {snip}");
924 assert!(snip.contains("let chunks"), "rust: {snip}");
925 assert!(snip.contains(".expect("), "rust must unwrap Result items: {snip}");
927 }
928
929 #[test]
930 fn collect_snippet_go_drains_channel() {
931 let snip = StreamingFieldResolver::collect_snippet("go", "stream", "chunks").unwrap();
932 assert!(snip.contains("for chunk := range stream"), "go: {snip}");
933 }
934
935 #[test]
936 fn collect_snippet_java_uses_iterator() {
937 let snip = StreamingFieldResolver::collect_snippet("java", "result", "chunks").unwrap();
938 assert!(snip.contains("hasNext()"), "java: {snip}");
939 }
940
941 #[test]
942 fn collect_snippet_php_decodes_json_or_iterates() {
943 let snip = StreamingFieldResolver::collect_snippet("php", "result", "chunks").unwrap();
944 assert!(snip.contains("json_decode"), "php must decode JSON: {snip}");
949 assert!(
950 snip.contains("iterator_to_array"),
951 "php must keep iterator_to_array fallback: {snip}"
952 );
953 assert!(snip.contains("$chunks ="), "php must bind $chunks: {snip}");
954 }
955
956 #[test]
957 fn collect_snippet_node_uses_for_await() {
958 let snip = StreamingFieldResolver::collect_snippet("node", "result", "chunks").unwrap();
959 assert!(snip.contains("for await"), "node: {snip}");
960 }
961
962 #[test]
963 fn collect_snippet_python_uses_async_for() {
964 let snip = StreamingFieldResolver::collect_snippet("python", "result", "chunks").unwrap();
965 assert!(snip.contains("async for chunk in result"), "python: {snip}");
966 assert!(snip.contains("chunks.append(chunk)"), "python: {snip}");
967 }
968
969 #[test]
970 fn accessor_stream_content_python_uses_join() {
971 let expr = StreamingFieldResolver::accessor("stream_content", "python", "chunks").unwrap();
972 assert!(expr.contains("\"\".join("), "python stream_content: {expr}");
973 assert!(expr.contains("c.choices"), "python stream_content: {expr}");
974 }
975
976 #[test]
977 fn accessor_stream_complete_python_uses_finish_reason() {
978 let expr = StreamingFieldResolver::accessor("stream_complete", "python", "chunks").unwrap();
979 assert!(
980 expr.contains("finish_reason is not None"),
981 "python stream_complete: {expr}"
982 );
983 }
984
985 #[test]
986 fn accessor_finish_reason_python_uses_last_chunk() {
987 let expr = StreamingFieldResolver::accessor("finish_reason", "python", "chunks").unwrap();
988 assert!(expr.contains("chunks[-1]"), "python finish_reason: {expr}");
989 assert!(
991 expr.starts_with("(str(") || expr.contains("str(chunks"),
992 "python finish_reason must wrap in str(): {expr}"
993 );
994 }
995
996 #[test]
997 fn accessor_tool_calls_python_uses_list_comprehension() {
998 let expr = StreamingFieldResolver::accessor("tool_calls", "python", "chunks").unwrap();
999 assert!(expr.contains("for c in chunks"), "python tool_calls: {expr}");
1000 assert!(expr.contains("tool_calls"), "python tool_calls: {expr}");
1001 }
1002
1003 #[test]
1004 fn accessor_usage_python_uses_last_chunk() {
1005 let expr = StreamingFieldResolver::accessor("usage", "python", "chunks").unwrap();
1006 assert!(
1007 expr.contains("chunks[-1].usage"),
1008 "python usage: expected chunks[-1].usage, got: {expr}"
1009 );
1010 }
1011
1012 #[test]
1013 fn accessor_usage_total_tokens_does_not_route_via_chunks() {
1014 assert!(StreamingFieldResolver::accessor("usage.total_tokens", "python", "chunks").is_none());
1018 }
1019
1020 #[test]
1021 fn accessor_unknown_field_returns_none() {
1022 assert_eq!(
1023 StreamingFieldResolver::accessor("nonexistent_field", "rust", "chunks"),
1024 None
1025 );
1026 }
1027
1028 #[test]
1033 fn is_streaming_virtual_field_recognizes_deep_tool_calls_paths() {
1034 assert!(
1035 is_streaming_virtual_field("tool_calls[0].function.name"),
1036 "tool_calls[0].function.name should be recognized"
1037 );
1038 assert!(
1039 is_streaming_virtual_field("tool_calls[0].id"),
1040 "tool_calls[0].id should be recognized"
1041 );
1042 assert!(
1043 is_streaming_virtual_field("tool_calls[1].function.arguments"),
1044 "tool_calls[1].function.arguments should be recognized"
1045 );
1046 assert!(is_streaming_virtual_field("tool_calls"));
1048 assert!(!is_streaming_virtual_field("tool_calls_extra.name"));
1050 assert!(!is_streaming_virtual_field("nonexistent[0].field"));
1051 }
1052
1053 #[test]
1060 fn deep_tool_calls_function_name_snapshot_rust_kotlin_ts() {
1061 let field = "tool_calls[0].function.name";
1062
1063 let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1064 assert!(
1068 rust.contains(".nth(0)"),
1069 "rust deep tool_calls: expected .nth(0) iterator index, got: {rust}"
1070 );
1071 assert!(
1072 rust.contains("x.function.as_ref()"),
1073 "rust deep tool_calls: expected Option-aware function access, got: {rust}"
1074 );
1075 assert!(
1076 rust.contains("x.name.as_deref()"),
1077 "rust deep tool_calls: expected Option-aware name leaf, got: {rust}"
1078 );
1079 assert!(
1080 !rust.contains("// skipped"),
1081 "rust deep tool_calls: must not emit skip comment, got: {rust}"
1082 );
1083
1084 let kotlin = StreamingFieldResolver::accessor(field, "kotlin", "chunks").unwrap();
1085 assert!(
1087 kotlin.contains(".first()"),
1088 "kotlin deep tool_calls: expected .first() for index 0, got: {kotlin}"
1089 );
1090 assert!(
1091 kotlin.contains(".function()"),
1092 "kotlin deep tool_calls: expected .function() method call, got: {kotlin}"
1093 );
1094 assert!(
1095 kotlin.contains(".name()"),
1096 "kotlin deep tool_calls: expected .name() method call, got: {kotlin}"
1097 );
1098
1099 let ts = StreamingFieldResolver::accessor(field, "node", "chunks").unwrap();
1100 assert!(
1102 ts.contains("[0]"),
1103 "ts/node deep tool_calls: expected [0] index, got: {ts}"
1104 );
1105 assert!(
1106 ts.contains(".function"),
1107 "ts/node deep tool_calls: expected .function segment, got: {ts}"
1108 );
1109 assert!(
1110 ts.contains(".name"),
1111 "ts/node deep tool_calls: expected .name segment, got: {ts}"
1112 );
1113 }
1114
1115 #[test]
1116 fn deep_tool_calls_id_snapshot_all_langs() {
1117 let field = "tool_calls[0].id";
1118
1119 let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1120 assert!(rust.contains(".nth(0)"), "rust: {rust}");
1121 assert!(rust.contains("x.id.as_deref()"), "rust: {rust}");
1122
1123 let go = StreamingFieldResolver::accessor(field, "go", "chunks").unwrap();
1124 assert!(go.contains("[0]"), "go: {go}");
1125 assert!(go.contains(".ID"), "go: expected .ID initialism, got: {go}");
1127
1128 let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1129 assert!(python.contains("[0]"), "python: {python}");
1130 assert!(python.contains(".id"), "python: {python}");
1131
1132 let php = StreamingFieldResolver::accessor(field, "php", "chunks").unwrap();
1133 assert!(php.contains("[0]"), "php: {php}");
1134 assert!(php.contains("->id"), "php: expected ->id, got: {php}");
1135
1136 let java = StreamingFieldResolver::accessor(field, "java", "chunks").unwrap();
1137 assert!(java.contains(".get(0)"), "java: expected .get(0), got: {java}");
1138 assert!(java.contains(".id()"), "java: expected .id() method call, got: {java}");
1139
1140 let csharp = StreamingFieldResolver::accessor(field, "csharp", "chunks").unwrap();
1141 assert!(csharp.contains("[0]"), "csharp: {csharp}");
1142 assert!(
1143 csharp.contains(".Id"),
1144 "csharp: expected .Id (PascalCase), got: {csharp}"
1145 );
1146
1147 let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1148 assert!(elixir.contains("Enum.at("), "elixir: expected Enum.at(, got: {elixir}");
1149 assert!(elixir.contains(".id"), "elixir: {elixir}");
1150 }
1151
1152 #[test]
1153 fn deep_tool_calls_function_name_snapshot_python_elixir_zig() {
1154 let field = "tool_calls[0].function.name";
1155
1156 let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1157 assert!(python.contains("[0]"), "python: {python}");
1158 assert!(python.contains(".function"), "python: {python}");
1159 assert!(python.contains(".name"), "python: {python}");
1160
1161 let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1162 assert!(elixir.contains("Enum.at("), "elixir: {elixir}");
1164 assert!(elixir.contains(".function"), "elixir: {elixir}");
1165 assert!(elixir.contains(".name"), "elixir: {elixir}");
1166
1167 assert!(
1171 StreamingFieldResolver::accessor(field, "zig", "chunks").is_none(),
1172 "zig: expected None for deep tool_calls path"
1173 );
1174 }
1175
1176 #[test]
1177 fn parse_tail_parses_index_then_field_segments() {
1178 let segs = parse_tail("[0].function.name");
1179 assert_eq!(segs.len(), 3, "expected 3 segments, got: {segs:?}");
1180 assert_eq!(segs[0], TailSeg::Index(0));
1181 assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1182 assert_eq!(segs[2], TailSeg::Field("name".to_string()));
1183 }
1184
1185 #[test]
1186 fn parse_tail_parses_simple_index_field() {
1187 let segs = parse_tail("[0].id");
1188 assert_eq!(segs.len(), 2, "expected 2 segments, got: {segs:?}");
1189 assert_eq!(segs[0], TailSeg::Index(0));
1190 assert_eq!(segs[1], TailSeg::Field("id".to_string()));
1191 }
1192
1193 #[test]
1194 fn parse_tail_handles_nonzero_index() {
1195 let segs = parse_tail("[2].function.arguments");
1196 assert_eq!(segs[0], TailSeg::Index(2));
1197 assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1198 assert_eq!(segs[2], TailSeg::Field("arguments".to_string()));
1199 }
1200}