Skip to main content

alef_e2e/codegen/
streaming_assertions.rs

1//! Shared streaming-virtual-fields module for e2e test codegen.
2//!
3//! Chat-stream fixtures assert on "virtual" fields that don't exist on the
4//! stream result type itself — `chunks`, `chunks.length`, `stream_content`,
5//! `stream_complete`, `no_chunks_after_done`, `tool_calls`, `finish_reason`.
6//! These fields resolve against the *collected* list of chunks produced by
7//! draining the stream.
8//!
9//! [`StreamingFieldResolver`] provides two entry points:
10//! - [`StreamingFieldResolver::accessor`] — the language-specific expression
11//!   for a virtual field given a local variable that holds the collected list.
12//! - [`StreamingFieldResolver::collect_snippet`] — the language-specific
13//!   code snippet that drains a stream variable into the collected list.
14//!
15//! ## Convention
16//!
17//! The `chunks_var` parameter is the local variable name that holds the
18//! collected list (default: `"chunks"`).  The `stream_var` parameter is the
19//! result variable produced by the stream call (default: `"result"`).
20//!
21//! The set of streaming-virtual field names handled by this module:
22//! - `chunks`              → the collected list itself
23//! - `chunks.length`       → length/count of the collected list
24//! - `stream_content`      → concatenation of all delta content strings
25//! - `stream_complete`     → boolean — last chunk has a non-null finish_reason
26//! - `no_chunks_after_done` → structural invariant (true by construction for
27//!   channel/iterator-based APIs once the channel is closed; emitted as
28//!   `assert!(true)` / `assertTrue` for languages without post-DONE chunk plumbing)
29//! - `tool_calls`          → flat list of tool_calls from all chunk deltas
30//! - `finish_reason`       → finish_reason string from the last chunk
31
32/// The set of field names treated as streaming-virtual fields.
33pub 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
43/// The set of streaming-virtual root names that may have deep-path continuations.
44///
45/// A field like `tool_calls[0].function.name` starts with `tool_calls` and has
46/// a continuation `[0].function.name`. These are handled by
47/// [`StreamingFieldResolver::accessor`] via the deep-path logic.
48///
49/// `usage` is a stream-level root: `usage.total_tokens` resolves against the
50/// last chunk that carried a usage payload (accessed via the collected chunks
51/// list). Python accessor: `(chunks[-1].usage if chunks else None)`.
52const STREAMING_VIRTUAL_ROOTS: &[&str] = &["tool_calls", "finish_reason"];
53
54/// Returns `true` when `field` is a streaming-virtual field name, including
55/// deep-nested paths that start with a known streaming-virtual root.
56///
57/// Examples that return `true`:
58/// - `"tool_calls"` (exact root)
59/// - `"tool_calls[0].function.name"` (deep path)
60/// - `"tool_calls[0].id"` (deep path)
61pub fn is_streaming_virtual_field(field: &str) -> bool {
62    if STREAMING_VIRTUAL_FIELDS.contains(&field) {
63        return true;
64    }
65    // Check deep-path prefixes: `tool_calls[…` or `tool_calls.`
66    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
77/// Split a field path into `(root, tail)` when it starts with a streaming-virtual
78/// root and has a continuation.
79///
80/// Returns `None` when the field is an exact root match (no tail) or is not a
81/// streaming-virtual root at all.
82fn 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
94/// Field names that unambiguously imply a streaming test (no overlap with
95/// non-streaming response shapes). `usage`, `tool_calls`, and `finish_reason`
96/// are intentionally excluded — they exist on non-streaming responses too
97/// (`usage.total_tokens` on ChatCompletionResponse, `choices[0].finish_reason`,
98/// etc.) and would otherwise drag non-streaming fixtures into streaming
99/// codegen.
100const STREAMING_ONLY_AUTO_DETECT_FIELDS: &[&str] = &[
101    "chunks",
102    "chunks.length",
103    "stream_content",
104    "stream_complete",
105    "no_chunks_after_done",
106];
107
108/// Resolve whether a fixture should be treated as streaming, honoring the
109/// call-level three-valued opt-in/out (`CallConfig::streaming`):
110///
111/// - `Some(true)` → forced streaming.
112/// - `Some(false)` → forced non-streaming (skip the auto-detect even when an
113///   assertion references a streaming-virtual-field name like `chunks`).
114/// - `None` → auto-detect: streaming iff the fixture has a streaming mock
115///   (`mock_response.stream_chunks`) or any assertion references one of the
116///   unambiguous streaming-only field names.
117///
118/// All backends should use this helper so the opt-out is respected uniformly.
119pub 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
131/// Shared streaming-virtual-fields resolver for e2e test codegen.
132pub struct StreamingFieldResolver;
133
134impl StreamingFieldResolver {
135    /// Returns the language-specific expression for a streaming-virtual field,
136    /// given `chunks_var` (the collected-list local name) and `lang`.
137    ///
138    /// Returns `None` when the field name is not a known streaming-virtual
139    /// field or the language has no streaming support.
140    pub fn accessor(field: &str, lang: &str, chunks_var: &str) -> Option<String> {
141        match field {
142            "chunks" => Some(match lang {
143                // Zig ArrayList does not expose .len directly; must use .items
144                "zig" => format!("{chunks_var}.items"),
145                // PHP variables require `$` sigil — bareword `chunks` is parsed as a
146                // constant reference and triggers "Undefined constant" errors.
147                "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 List.size is a property (not .length)
158                "kotlin" => format!("{chunks_var}.size"),
159                // zig: chunks_var is ArrayList([]u8); use .items.len
160                "zig" => format!("{chunks_var}.items.len"),
161                // node/wasm/typescript use .length
162                _ => 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                    // Go: chunks is []pkg.ChatCompletionChunk
173                    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                    // Kotlin: chunks is List<ChatCompletionChunk> (Java records via typealias).
187                    // choices() / delta() / content() are Java record accessor methods.
188                    format!(
189                        "{chunks_var}.joinToString(\"\") {{ it.choices()?.firstOrNull()?.delta()?.content() ?: \"\" }}"
190                    )
191                }
192                "elixir" => {
193                    // StreamDelta has all fields optional with skip_serializing_if, so
194                    // absent fields are not present as keys in the Jason-decoded map.
195                    // Use Map.get with defaults to avoid KeyError on absent :content.
196                    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                    // Zig: `{chunks_var}_content` is a `std.ArrayList(u8)` populated by
205                    // the collect snippet. `.items` gives a `[]u8` slice of the content.
206                    format!("{chunks_var}_content.items")
207                }
208                // node/wasm/typescript
209                _ => {
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                    // PHP streaming chunks come from `json_decode` of the binding's JSON
232                    // string return. The PHP binding serializes with rename_all = "camelCase",
233                    // so use `finishReason` (camelCase) to match the emitted JSON keys.
234                    format!("!empty(${chunks_var}) && isset(end(${chunks_var})->choices[0]->finishReason)")
235                }
236                "kotlin" => {
237                    // Kotlin: use isNotEmpty() + last() + safe-call chain
238                    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: the collect snippet exhausts the stream; check last chunk JSON
249                // was collected (chunks.items is non-empty) as a proxy for completion.
250                "zig" => {
251                    format!("{chunks_var}.items.len > 0")
252                }
253                // node/wasm/typescript
254                _ => {
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 is a structural invariant: once the stream
262            // closes (channel drained / iterator exhausted), no further chunks
263            // can arrive.  We assert `true` as a compile-time proof of intent.
264            "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                    // StreamDelta.ToolCalls is `[]StreamToolCall` (slice, not pointer).
280                    // Return the typed slice so deep-path accessors like `tool_calls[0].function.name`
281                    // can index and access typed fields.
282                    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                    // PHP streaming chunks are json_decoded stdClass. The PHP binding
293                    // serializes with rename_all = "camelCase", so use `toolCalls`.
294                    format!(
295                        "array_merge(...array_map(fn($c) => $c->choices[0]->delta->toolCalls ?? [], ${chunks_var}))"
296                    )
297                }
298                "kotlin" => {
299                    // Kotlin: flatten tool_calls from all chunk deltas into one list
300                    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: tool_calls count from all chunk deltas
315                "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                    // ChatCompletionChunk's finish_reason is Option<FinishReason> (enum, not
326                    // String). Display impl writes the JSON wire form (e.g. "tool_calls").
327                    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                    // FinishReason is a typed alias (`type FinishReason string`) in bindings,
333                    // so cast to string explicitly to match the assertion target type.
334                    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                    // FinishReason.getValue() returns the JSON wire string (e.g. "tool_calls").
340                    // Without it, assertEquals(String, FinishReason) fails because Object.equals
341                    // doesn't cross types even when toString() matches.
342                    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                    // PHP streaming chunks are json_decoded stdClass. The PHP binding
348                    // serializes with rename_all = "camelCase", so use `finishReason`.
349                    format!("(!empty(${chunks_var}) ? (end(${chunks_var})->choices[0]->finishReason ?? null) : null)")
350                }
351                "kotlin" => {
352                    // Returns the string value of the finish_reason enum from the last chunk.
353                    // FinishReason.getValue() returns the JSON wire string (e.g. "tool_calls").
354                    format!(
355                        "(if ({chunks_var}.isEmpty()) null else {chunks_var}.last().choices()?.firstOrNull()?.finishReason()?.getValue())"
356                    )
357                }
358                "python" => {
359                    // FinishReason is a PyO3 enum object, not a plain string.
360                    // Wrap in str() so callers can do `.strip()` / string comparisons
361                    // without `AttributeError: 'FinishReason' has no attribute 'strip'`.
362                    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: finish_reason from the last chunk's JSON via an inline labeled block.
370                // Returns `[]const u8` (unwrapped with orelse "" for expectEqualStrings).
371                "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` is a stream-level virtual root: resolves against the last
384            // chunk that carried a usage payload.  Deep paths like `usage.total_tokens`
385            // are handled by the deep-path logic in the `_` arm below (root=`usage`,
386            // tail=`.total_tokens`), which calls this base accessor and appends the tail.
387            "usage" => Some(match lang {
388                "python" => {
389                    // Access the last chunk's usage object (may be None).
390                    // Deep paths like usage.total_tokens are rendered as:
391                    //   (chunks[-1].usage if chunks else None).total_tokens
392                    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                // Deep-path: e.g. `tool_calls[0].function.name`
421                // Split into root + tail, get the root's inline expression, then
422                // render the tail (index + fields) in a per-language style on top.
423                if let Some((root, tail)) = split_streaming_deep_path(field) {
424                    // Rust needs Option-aware chaining for the StreamToolCall fields
425                    // (function/id are Option<...>). The generic tail renderer can't
426                    // infer Option-wrapping, so we emit rust-specific idiom here.
427                    if lang == "rust" && root == "tool_calls" {
428                        return Some(render_rust_tool_calls_deep(chunks_var, tail));
429                    }
430                    // Zig stores stream chunks as JSON strings (`[]const u8`) in
431                    // `chunks: ArrayList([]u8)`, not typed `ChatCompletionChunk`
432                    // records. A deep `tool_calls[N].function.name` access would
433                    // require parsing each chunk's JSON inline — rather than
434                    // emit code that won't compile, signal "unsupported" so the
435                    // assertion is skipped at the call site.
436                    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    /// Returns the language-specific stream-collect-into-list snippet that
449    /// produces `chunks_var` from `stream_var`.
450    ///
451    /// Returns `None` when the language has no streaming collect support or
452    /// when the collect snippet cannot be expressed generically.
453    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 binding's chat_stream_async typically returns a JSON string of the
465            // chunk array (PHP cannot expose Rust iterators directly via ext-php-rs).
466            // Decode to an array of stdClass objects so accessor chains like
467            // `$c->choices[0]->delta->content` resolve against the JSON wire shape
468            // (snake_case keys).  Falls back to iterator_to_array for a future binding
469            // upgrade that exposes a real iterator.
470            "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                // Kotlin: chatStream returns Iterator<ChatCompletionChunk> (from Java bridge).
478                // Drain into a Kotlin List using asSequence().toList().
479                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            "zig" => {
486                // Zig 0.16: ArrayList is unmanaged — no stored allocator.
487                // Use `.empty` to initialize, pass `std.heap.c_allocator` to each mutation.
488                // `stream_var` is the opaque stream handle obtained via `_start`.
489                // We collect every chunk's JSON string into `chunks_var: ArrayList([]u8)`
490                // and concatenate delta content into `{chunks_var}_content: ArrayList(u8)`.
491                // Accessors use `.items.len` and `{chunks_var}_content.items` on these lists.
492                Some(format!(
493                    concat!(
494                        "var {chunks_var}: std.ArrayList([]u8) = .empty;
495",
496                        "    defer {{
497",
498                        "        for ({chunks_var}.items) |_cj| std.heap.c_allocator.free(_cj);
499",
500                        "        {chunks_var}.deinit(std.heap.c_allocator);
501",
502                        "    }}
503",
504                        "    var {chunks_var}_content: std.ArrayList(u8) = .empty;
505",
506                        "    defer {chunks_var}_content.deinit(std.heap.c_allocator);
507",
508                        "    while (true) {{
509",
510                        "        const _nc = liter_llm.c.literllm_default_client_chat_stream_next({stream_var});
511",
512                        "        if (_nc == null) break;
513",
514                        "        const _np = liter_llm.c.literllm_chat_completion_chunk_to_json(_nc);
515",
516                        "        liter_llm.c.literllm_chat_completion_chunk_free(_nc);
517",
518                        "        if (_np == null) continue;
519",
520                        "        const _ns = std.mem.span(_np);
521",
522                        "        const _nj = try std.heap.c_allocator.dupe(u8, _ns);
523",
524                        "        liter_llm.c.literllm_free_string(_np);
525",
526                        "        if (std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, _nj, .{{}})) |_cp| {{
527",
528                        "            defer _cp.deinit();
529",
530                        "            if (_cp.value.object.get(\"choices\")) |_chs|
531",
532                        "                if (_chs.array.items.len > 0)
533",
534                        "                    if (_chs.array.items[0].object.get(\"delta\")) |_dl|
535",
536                        "                        if (_dl.object.get(\"content\")) |_ct|
537",
538                        "                            if (_ct == .string) try {chunks_var}_content.appendSlice(std.heap.c_allocator, _ct.string);
539",
540                        "        }} else |_| {{}}
541",
542                        "        try {chunks_var}.append(std.heap.c_allocator, _nj);
543",
544                        "    }}"
545                    ),
546                    chunks_var = chunks_var,
547                    stream_var = stream_var,
548                ))
549            }
550            _ => None,
551        }
552    }
553}
554
555/// Render a rust deep accessor for `tool_calls[N]...` paths over the flattened
556/// stream-chunk tool_calls iterator. Handles Option-wrapped fields by chaining
557/// `as_ref().and_then(...)` so the final value is a `&str` (for name/id/arguments).
558fn render_rust_tool_calls_deep(chunks_var: &str, tail: &str) -> String {
559    let segs = parse_tail(tail);
560    // Locate index segment (rust uses .nth(n) on the iterator instead of [N] on a Vec)
561    let idx = segs.iter().find_map(|s| match s {
562        TailSeg::Index(n) => Some(*n),
563        _ => None,
564    });
565    let field_segs: Vec<&str> = segs
566        .iter()
567        .filter_map(|s| match s {
568            TailSeg::Field(f) => Some(f.as_str()),
569            _ => None,
570        })
571        .collect();
572
573    let base = format!(
574        "{chunks_var}.iter().flat_map(|c| c.choices.iter().flat_map(|ch| ch.delta.tool_calls.iter().flatten()))"
575    );
576    let with_nth = match idx {
577        Some(n) => format!("{base}.nth({n})"),
578        None => base,
579    };
580
581    // Chain Option-aware field access. Every field on StreamToolCall is Option<...>;
582    // the leaf (String fields) uses `.as_deref()` to project to `&str`.
583    let mut expr = with_nth;
584    for (i, f) in field_segs.iter().enumerate() {
585        let is_leaf = i == field_segs.len() - 1;
586        if is_leaf {
587            expr = format!("{expr}.and_then(|x| x.{f}.as_deref())");
588        } else {
589            expr = format!("{expr}.and_then(|x| x.{f}.as_ref())");
590        }
591    }
592    format!("{expr}.unwrap_or(\"\")")
593}
594
595/// Parse a deep-path tail (e.g. `[0].function.name`) into structured segments.
596///
597/// The tail always starts with either `[N]` (array index) or `.field`.
598/// Returns a list of segments: `TailSeg::Index(N)` or `TailSeg::Field(name)`.
599#[derive(Debug, PartialEq)]
600enum TailSeg {
601    Index(usize),
602    Field(String),
603}
604
605fn parse_tail(tail: &str) -> Vec<TailSeg> {
606    let mut segs = Vec::new();
607    let mut rest = tail;
608    while !rest.is_empty() {
609        if let Some(inner) = rest.strip_prefix('[') {
610            // Array index: `[N]`
611            if let Some(close) = inner.find(']') {
612                let idx_str = &inner[..close];
613                if let Ok(idx) = idx_str.parse::<usize>() {
614                    segs.push(TailSeg::Index(idx));
615                }
616                rest = &inner[close + 1..];
617            } else {
618                break;
619            }
620        } else if let Some(inner) = rest.strip_prefix('.') {
621            // Field name: up to next `.` or `[`
622            let end = inner.find(['.', '[']).unwrap_or(inner.len());
623            segs.push(TailSeg::Field(inner[..end].to_string()));
624            rest = &inner[end..];
625        } else {
626            break;
627        }
628    }
629    segs
630}
631
632/// Render the full deep accessor expression by appending per-language tail
633/// segments onto `root_expr`.
634fn render_deep_tail(root_expr: &str, tail: &str, lang: &str) -> String {
635    use heck::{ToLowerCamelCase, ToPascalCase};
636
637    let segs = parse_tail(tail);
638    let mut out = root_expr.to_string();
639
640    for seg in &segs {
641        match (seg, lang) {
642            (TailSeg::Index(n), "rust") => {
643                out = format!("({out})[{n}]");
644            }
645            (TailSeg::Index(n), "java") => {
646                out = format!("({out}).get({n})");
647            }
648            (TailSeg::Index(n), "kotlin") => {
649                if *n == 0 {
650                    out = format!("({out}).first()");
651                } else {
652                    out = format!("({out}).get({n})");
653                }
654            }
655            (TailSeg::Index(n), "elixir") => {
656                out = format!("Enum.at({out}, {n})");
657            }
658            (TailSeg::Index(n), "zig") => {
659                out = format!("({out}).items[{n}]");
660            }
661            (TailSeg::Index(n), "php") => {
662                out = format!("({out})[{n}]");
663            }
664            (TailSeg::Index(n), _) => {
665                // rust-like for go (but we handle Field differently), python, node, ts, kotlin, etc.
666                out = format!("({out})[{n}]");
667            }
668            (TailSeg::Field(f), "rust") => {
669                use heck::ToSnakeCase;
670                out.push('.');
671                out.push_str(&f.to_snake_case());
672            }
673            (TailSeg::Field(f), "go") => {
674                use alef_codegen::naming::to_go_name;
675                out.push('.');
676                out.push_str(&to_go_name(f));
677            }
678            (TailSeg::Field(f), "java") => {
679                out.push('.');
680                out.push_str(&f.to_lower_camel_case());
681                out.push_str("()");
682            }
683            (TailSeg::Field(f), "kotlin") => {
684                // Use safe-call `?.` for all field accessors in Kotlin deep paths.
685                // All streaming tool-call sub-fields (`function`, `id`, `name`,
686                // `arguments`) are nullable in the generated Java records, so `?.`
687                // is always correct here and prevents "non-null asserted call on
688                // nullable receiver" compile errors.
689                out.push_str("?.");
690                out.push_str(&f.to_lower_camel_case());
691                out.push_str("()");
692            }
693            (TailSeg::Field(f), "csharp") => {
694                out.push('.');
695                out.push_str(&f.to_pascal_case());
696            }
697            (TailSeg::Field(f), "php") => {
698                // Streaming PHP accessors operate on json_decoded stdClass with
699                // snake_case property names (JSON wire format), not the camelCase
700                // properties exposed on the PHP wrapper class. Use the raw field
701                // name verbatim.
702                out.push_str("->");
703                out.push_str(f);
704            }
705            (TailSeg::Field(f), "elixir") => {
706                out.push('.');
707                out.push_str(f);
708            }
709            (TailSeg::Field(f), "zig") => {
710                out.push('.');
711                out.push_str(f);
712            }
713            (TailSeg::Field(f), "python") | (TailSeg::Field(f), "ruby") => {
714                out.push('.');
715                out.push_str(f);
716            }
717            // node, wasm, typescript, kotlin, dart, swift all use camelCase
718            (TailSeg::Field(f), _) => {
719                out.push('.');
720                out.push_str(&f.to_lower_camel_case());
721            }
722        }
723    }
724
725    out
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn is_streaming_virtual_field_recognizes_all_fields() {
734        for field in STREAMING_VIRTUAL_FIELDS {
735            assert!(
736                is_streaming_virtual_field(field),
737                "field '{field}' not recognized as streaming virtual"
738            );
739        }
740    }
741
742    #[test]
743    fn is_streaming_virtual_field_rejects_real_fields() {
744        assert!(!is_streaming_virtual_field("content"));
745        assert!(!is_streaming_virtual_field("choices"));
746        assert!(!is_streaming_virtual_field("model"));
747        assert!(!is_streaming_virtual_field(""));
748    }
749
750    #[test]
751    fn is_streaming_virtual_field_rejects_non_root_paths_with_matching_tail() {
752        // Regression: prior impl matched any field whose chars-after-root-len started
753        // with `[` or `.` — without checking that the field actually starts with the
754        // root token. `choices[0].finish_reason` therefore falsely matched root
755        // `tool_calls` because byte 10 onward is `.finish_reason`.
756        assert!(!is_streaming_virtual_field("choices[0].finish_reason"));
757        assert!(!is_streaming_virtual_field("choices[0].message.content"));
758        assert!(!is_streaming_virtual_field("data[0].embedding"));
759    }
760
761    #[test]
762    fn is_streaming_virtual_field_does_not_match_usage() {
763        // `usage` is intentionally NOT a streaming-virtual root: chat/embed
764        // responses carry `usage.total_tokens` at the response root, so treating
765        // it as virtual would drag non-streaming tests into the chunks accessor.
766        assert!(!is_streaming_virtual_field("usage"));
767        assert!(!is_streaming_virtual_field("usage.total_tokens"));
768        assert!(!is_streaming_virtual_field("usage.prompt_tokens"));
769    }
770
771    #[test]
772    fn accessor_chunks_returns_var_name() {
773        assert_eq!(
774            StreamingFieldResolver::accessor("chunks", "rust", "chunks"),
775            Some("chunks".to_string())
776        );
777        assert_eq!(
778            StreamingFieldResolver::accessor("chunks", "node", "chunks"),
779            Some("chunks".to_string())
780        );
781    }
782
783    #[test]
784    fn accessor_chunks_length_uses_language_idiom() {
785        let rust = StreamingFieldResolver::accessor("chunks.length", "rust", "chunks").unwrap();
786        assert!(rust.contains(".len()"), "rust: {rust}");
787
788        let go = StreamingFieldResolver::accessor("chunks.length", "go", "chunks").unwrap();
789        assert!(go.starts_with("len("), "go: {go}");
790
791        let node = StreamingFieldResolver::accessor("chunks.length", "node", "chunks").unwrap();
792        assert!(node.contains(".length"), "node: {node}");
793
794        let php = StreamingFieldResolver::accessor("chunks.length", "php", "chunks").unwrap();
795        assert!(php.starts_with("count("), "php: {php}");
796    }
797
798    #[test]
799    fn accessor_chunks_length_zig_uses_items_len() {
800        let zig = StreamingFieldResolver::accessor("chunks.length", "zig", "chunks").unwrap();
801        assert_eq!(zig, "chunks.items.len", "zig chunks.length: {zig}");
802    }
803
804    #[test]
805    fn accessor_stream_content_zig_uses_content_items() {
806        let zig = StreamingFieldResolver::accessor("stream_content", "zig", "chunks").unwrap();
807        assert_eq!(zig, "chunks_content.items", "zig stream_content: {zig}");
808    }
809
810    #[test]
811    fn collect_snippet_zig_drains_via_ffi() {
812        let snip = StreamingFieldResolver::collect_snippet("zig", "_stream_handle", "chunks").unwrap();
813        assert!(snip.contains("std.ArrayList([]u8)"), "zig collect: {snip}");
814        assert!(snip.contains("chat_stream_next(_stream_handle)"), "zig collect: {snip}");
815        assert!(snip.contains("chunks_content"), "zig collect: {snip}");
816        assert!(
817            snip.contains("chunks.append(std.heap.c_allocator"),
818            "zig collect: {snip}"
819        );
820        assert!(snip.contains(".empty;"), "zig collect (Zig 0.16 unmanaged): {snip}");
821    }
822
823    #[test]
824    fn accessor_stream_content_rust_uses_iterator() {
825        let expr = StreamingFieldResolver::accessor("stream_content", "rust", "chunks").unwrap();
826        assert!(expr.contains(".collect::<String>()"), "rust stream_content: {expr}");
827    }
828
829    #[test]
830    fn accessor_no_chunks_after_done_returns_true() {
831        for lang in ["rust", "go", "java", "php", "node", "wasm", "elixir"] {
832            let expr = StreamingFieldResolver::accessor("no_chunks_after_done", lang, "chunks").unwrap();
833            assert_eq!(expr, "true", "lang {lang}: expected 'true', got '{expr}'");
834        }
835    }
836
837    #[test]
838    fn accessor_elixir_chunks_length_uses_length_function() {
839        let expr = StreamingFieldResolver::accessor("chunks.length", "elixir", "chunks").unwrap();
840        assert_eq!(expr, "length(chunks)", "elixir chunks.length: {expr}");
841    }
842
843    #[test]
844    fn accessor_elixir_stream_content_uses_pipe() {
845        let expr = StreamingFieldResolver::accessor("stream_content", "elixir", "chunks").unwrap();
846        assert!(expr.contains("|> Enum.join"), "elixir stream_content: {expr}");
847        assert!(expr.contains("|> Enum.map"), "elixir stream_content: {expr}");
848        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
849        assert!(
850            !expr.contains("choices[0]"),
851            "elixir stream_content must not use bracket access on list: {expr}"
852        );
853        assert!(
854            expr.contains("Enum.at("),
855            "elixir stream_content must use Enum.at for list index: {expr}"
856        );
857    }
858
859    #[test]
860    fn accessor_elixir_stream_complete_uses_list_last() {
861        let expr = StreamingFieldResolver::accessor("stream_complete", "elixir", "chunks").unwrap();
862        assert!(expr.contains("List.last(chunks)"), "elixir stream_complete: {expr}");
863        assert!(expr.contains("finish_reason != nil"), "elixir stream_complete: {expr}");
864        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
865        assert!(
866            !expr.contains("choices[0]"),
867            "elixir stream_complete must not use bracket access on list: {expr}"
868        );
869        assert!(
870            expr.contains("Enum.at("),
871            "elixir stream_complete must use Enum.at for list index: {expr}"
872        );
873    }
874
875    #[test]
876    fn accessor_elixir_finish_reason_uses_list_last() {
877        let expr = StreamingFieldResolver::accessor("finish_reason", "elixir", "chunks").unwrap();
878        assert!(expr.contains("List.last(chunks)"), "elixir finish_reason: {expr}");
879        assert!(expr.contains("finish_reason"), "elixir finish_reason: {expr}");
880        // Elixir lists do not support bracket access — must use Enum.at, never choices[0]
881        assert!(
882            !expr.contains("choices[0]"),
883            "elixir finish_reason must not use bracket access on list: {expr}"
884        );
885        assert!(
886            expr.contains("Enum.at("),
887            "elixir finish_reason must use Enum.at for list index: {expr}"
888        );
889    }
890
891    #[test]
892    fn collect_snippet_elixir_uses_enum_to_list() {
893        let snip = StreamingFieldResolver::collect_snippet("elixir", "result", "chunks").unwrap();
894        assert!(snip.contains("Enum.to_list(result)"), "elixir: {snip}");
895        assert!(snip.contains("chunks ="), "elixir: {snip}");
896    }
897
898    #[test]
899    fn collect_snippet_rust_uses_tokio_stream() {
900        let snip = StreamingFieldResolver::collect_snippet("rust", "result", "chunks").unwrap();
901        assert!(snip.contains("tokio_stream::StreamExt::collect"), "rust: {snip}");
902        assert!(snip.contains("let chunks"), "rust: {snip}");
903        // Items are Result<ChatCompletionChunk, _> — unwrap so chunks is Vec<ChatCompletionChunk>
904        assert!(snip.contains(".expect("), "rust must unwrap Result items: {snip}");
905    }
906
907    #[test]
908    fn collect_snippet_go_drains_channel() {
909        let snip = StreamingFieldResolver::collect_snippet("go", "stream", "chunks").unwrap();
910        assert!(snip.contains("for chunk := range stream"), "go: {snip}");
911    }
912
913    #[test]
914    fn collect_snippet_java_uses_iterator() {
915        let snip = StreamingFieldResolver::collect_snippet("java", "result", "chunks").unwrap();
916        assert!(snip.contains("hasNext()"), "java: {snip}");
917    }
918
919    #[test]
920    fn collect_snippet_php_decodes_json_or_iterates() {
921        let snip = StreamingFieldResolver::collect_snippet("php", "result", "chunks").unwrap();
922        // PHP binding's chat_stream_async returns a JSON string today; collect-snippet
923        // decodes it.  iterator_to_array is retained as the fallback branch so a
924        // future binding that exposes a real iterator continues to work without
925        // regenerating the e2e tests.
926        assert!(snip.contains("json_decode"), "php must decode JSON: {snip}");
927        assert!(
928            snip.contains("iterator_to_array"),
929            "php must keep iterator_to_array fallback: {snip}"
930        );
931        assert!(snip.contains("$chunks ="), "php must bind $chunks: {snip}");
932    }
933
934    #[test]
935    fn collect_snippet_node_uses_for_await() {
936        let snip = StreamingFieldResolver::collect_snippet("node", "result", "chunks").unwrap();
937        assert!(snip.contains("for await"), "node: {snip}");
938    }
939
940    #[test]
941    fn collect_snippet_python_uses_async_for() {
942        let snip = StreamingFieldResolver::collect_snippet("python", "result", "chunks").unwrap();
943        assert!(snip.contains("async for chunk in result"), "python: {snip}");
944        assert!(snip.contains("chunks.append(chunk)"), "python: {snip}");
945    }
946
947    #[test]
948    fn accessor_stream_content_python_uses_join() {
949        let expr = StreamingFieldResolver::accessor("stream_content", "python", "chunks").unwrap();
950        assert!(expr.contains("\"\".join("), "python stream_content: {expr}");
951        assert!(expr.contains("c.choices"), "python stream_content: {expr}");
952    }
953
954    #[test]
955    fn accessor_stream_complete_python_uses_finish_reason() {
956        let expr = StreamingFieldResolver::accessor("stream_complete", "python", "chunks").unwrap();
957        assert!(
958            expr.contains("finish_reason is not None"),
959            "python stream_complete: {expr}"
960        );
961    }
962
963    #[test]
964    fn accessor_finish_reason_python_uses_last_chunk() {
965        let expr = StreamingFieldResolver::accessor("finish_reason", "python", "chunks").unwrap();
966        assert!(expr.contains("chunks[-1]"), "python finish_reason: {expr}");
967        // Must wrap in str() so FinishReason enum objects support .strip() comparisons
968        assert!(
969            expr.starts_with("(str(") || expr.contains("str(chunks"),
970            "python finish_reason must wrap in str(): {expr}"
971        );
972    }
973
974    #[test]
975    fn accessor_tool_calls_python_uses_list_comprehension() {
976        let expr = StreamingFieldResolver::accessor("tool_calls", "python", "chunks").unwrap();
977        assert!(expr.contains("for c in chunks"), "python tool_calls: {expr}");
978        assert!(expr.contains("tool_calls"), "python tool_calls: {expr}");
979    }
980
981    #[test]
982    fn accessor_usage_python_uses_last_chunk() {
983        let expr = StreamingFieldResolver::accessor("usage", "python", "chunks").unwrap();
984        assert!(
985            expr.contains("chunks[-1].usage"),
986            "python usage: expected chunks[-1].usage, got: {expr}"
987        );
988    }
989
990    #[test]
991    fn accessor_usage_total_tokens_does_not_route_via_chunks() {
992        // `usage` is intentionally NOT a streaming-virtual root (it overlaps the
993        // non-streaming response shape). The accessor must return None so the
994        // assertion falls through to the normal field-path codegen.
995        assert!(StreamingFieldResolver::accessor("usage.total_tokens", "python", "chunks").is_none());
996    }
997
998    #[test]
999    fn accessor_unknown_field_returns_none() {
1000        assert_eq!(
1001            StreamingFieldResolver::accessor("nonexistent_field", "rust", "chunks"),
1002            None
1003        );
1004    }
1005
1006    // -----------------------------------------------------------------------
1007    // Deep-path tests: tool_calls[0].function.name and tool_calls[0].id
1008    // -----------------------------------------------------------------------
1009
1010    #[test]
1011    fn is_streaming_virtual_field_recognizes_deep_tool_calls_paths() {
1012        assert!(
1013            is_streaming_virtual_field("tool_calls[0].function.name"),
1014            "tool_calls[0].function.name should be recognized"
1015        );
1016        assert!(
1017            is_streaming_virtual_field("tool_calls[0].id"),
1018            "tool_calls[0].id should be recognized"
1019        );
1020        assert!(
1021            is_streaming_virtual_field("tool_calls[1].function.arguments"),
1022            "tool_calls[1].function.arguments should be recognized"
1023        );
1024        // bare root still recognized
1025        assert!(is_streaming_virtual_field("tool_calls"));
1026        // unrelated deep path must NOT be recognized
1027        assert!(!is_streaming_virtual_field("tool_calls_extra.name"));
1028        assert!(!is_streaming_virtual_field("nonexistent[0].field"));
1029    }
1030
1031    /// Snapshot: `tool_calls[0].function.name` for Rust, Kotlin, TypeScript.
1032    ///
1033    /// These three languages cover the main accessor styles:
1034    /// - Rust: snake_case field, explicit `[0]` index on collected Vec
1035    /// - Kotlin: camelCase method calls with `.first()` for index 0
1036    /// - TypeScript/Node: camelCase properties with `[0]` bracket
1037    #[test]
1038    fn deep_tool_calls_function_name_snapshot_rust_kotlin_ts() {
1039        let field = "tool_calls[0].function.name";
1040
1041        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1042        // Rust: Option-aware chain over the iterator — `.nth(0)` then `.and_then`
1043        // on each Option-wrapped field (function is Option<StreamFunctionCall>,
1044        // name is Option<String>). Final `.unwrap_or("")` yields `&str`.
1045        assert!(
1046            rust.contains(".nth(0)"),
1047            "rust deep tool_calls: expected .nth(0) iterator index, got: {rust}"
1048        );
1049        assert!(
1050            rust.contains("x.function.as_ref()"),
1051            "rust deep tool_calls: expected Option-aware function access, got: {rust}"
1052        );
1053        assert!(
1054            rust.contains("x.name.as_deref()"),
1055            "rust deep tool_calls: expected Option-aware name leaf, got: {rust}"
1056        );
1057        assert!(
1058            !rust.contains("// skipped"),
1059            "rust deep tool_calls: must not emit skip comment, got: {rust}"
1060        );
1061
1062        let kotlin = StreamingFieldResolver::accessor(field, "kotlin", "chunks").unwrap();
1063        // Kotlin: uses .first() for index 0, then .function().name()
1064        assert!(
1065            kotlin.contains(".first()"),
1066            "kotlin deep tool_calls: expected .first() for index 0, got: {kotlin}"
1067        );
1068        assert!(
1069            kotlin.contains(".function()"),
1070            "kotlin deep tool_calls: expected .function() method call, got: {kotlin}"
1071        );
1072        assert!(
1073            kotlin.contains(".name()"),
1074            "kotlin deep tool_calls: expected .name() method call, got: {kotlin}"
1075        );
1076
1077        let ts = StreamingFieldResolver::accessor(field, "node", "chunks").unwrap();
1078        // TypeScript/Node: uses [0] then .function.name (camelCase)
1079        assert!(
1080            ts.contains("[0]"),
1081            "ts/node deep tool_calls: expected [0] index, got: {ts}"
1082        );
1083        assert!(
1084            ts.contains(".function"),
1085            "ts/node deep tool_calls: expected .function segment, got: {ts}"
1086        );
1087        assert!(
1088            ts.contains(".name"),
1089            "ts/node deep tool_calls: expected .name segment, got: {ts}"
1090        );
1091    }
1092
1093    #[test]
1094    fn deep_tool_calls_id_snapshot_all_langs() {
1095        let field = "tool_calls[0].id";
1096
1097        let rust = StreamingFieldResolver::accessor(field, "rust", "chunks").unwrap();
1098        assert!(rust.contains(".nth(0)"), "rust: {rust}");
1099        assert!(rust.contains("x.id.as_deref()"), "rust: {rust}");
1100
1101        let go = StreamingFieldResolver::accessor(field, "go", "chunks").unwrap();
1102        assert!(go.contains("[0]"), "go: {go}");
1103        // Go: ID is a well-known initialism → uppercase
1104        assert!(go.contains(".ID"), "go: expected .ID initialism, got: {go}");
1105
1106        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1107        assert!(python.contains("[0]"), "python: {python}");
1108        assert!(python.contains(".id"), "python: {python}");
1109
1110        let php = StreamingFieldResolver::accessor(field, "php", "chunks").unwrap();
1111        assert!(php.contains("[0]"), "php: {php}");
1112        assert!(php.contains("->id"), "php: expected ->id, got: {php}");
1113
1114        let java = StreamingFieldResolver::accessor(field, "java", "chunks").unwrap();
1115        assert!(java.contains(".get(0)"), "java: expected .get(0), got: {java}");
1116        assert!(java.contains(".id()"), "java: expected .id() method call, got: {java}");
1117
1118        let csharp = StreamingFieldResolver::accessor(field, "csharp", "chunks").unwrap();
1119        assert!(csharp.contains("[0]"), "csharp: {csharp}");
1120        assert!(
1121            csharp.contains(".Id"),
1122            "csharp: expected .Id (PascalCase), got: {csharp}"
1123        );
1124
1125        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1126        assert!(elixir.contains("Enum.at("), "elixir: expected Enum.at(, got: {elixir}");
1127        assert!(elixir.contains(".id"), "elixir: {elixir}");
1128    }
1129
1130    #[test]
1131    fn deep_tool_calls_function_name_snapshot_python_elixir_zig() {
1132        let field = "tool_calls[0].function.name";
1133
1134        let python = StreamingFieldResolver::accessor(field, "python", "chunks").unwrap();
1135        assert!(python.contains("[0]"), "python: {python}");
1136        assert!(python.contains(".function"), "python: {python}");
1137        assert!(python.contains(".name"), "python: {python}");
1138
1139        let elixir = StreamingFieldResolver::accessor(field, "elixir", "chunks").unwrap();
1140        // Elixir: Enum.at(…, 0).function.name
1141        assert!(elixir.contains("Enum.at("), "elixir: {elixir}");
1142        assert!(elixir.contains(".function"), "elixir: {elixir}");
1143        assert!(elixir.contains(".name"), "elixir: {elixir}");
1144
1145        // Zig stores chunks as JSON strings, not typed records — deep
1146        // tool_calls paths are unsupported and resolve to None so the
1147        // assertion site can skip them.
1148        assert!(
1149            StreamingFieldResolver::accessor(field, "zig", "chunks").is_none(),
1150            "zig: expected None for deep tool_calls path"
1151        );
1152    }
1153
1154    #[test]
1155    fn parse_tail_parses_index_then_field_segments() {
1156        let segs = parse_tail("[0].function.name");
1157        assert_eq!(segs.len(), 3, "expected 3 segments, got: {segs:?}");
1158        assert_eq!(segs[0], TailSeg::Index(0));
1159        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1160        assert_eq!(segs[2], TailSeg::Field("name".to_string()));
1161    }
1162
1163    #[test]
1164    fn parse_tail_parses_simple_index_field() {
1165        let segs = parse_tail("[0].id");
1166        assert_eq!(segs.len(), 2, "expected 2 segments, got: {segs:?}");
1167        assert_eq!(segs[0], TailSeg::Index(0));
1168        assert_eq!(segs[1], TailSeg::Field("id".to_string()));
1169    }
1170
1171    #[test]
1172    fn parse_tail_handles_nonzero_index() {
1173        let segs = parse_tail("[2].function.arguments");
1174        assert_eq!(segs[0], TailSeg::Index(2));
1175        assert_eq!(segs[1], TailSeg::Field("function".to_string()));
1176        assert_eq!(segs[2], TailSeg::Field("arguments".to_string()));
1177    }
1178}