Skip to main content

agentic_core/executor/
messages_stream.rs

1//! Streaming Messages-native gateway tool loop.
2//!
3//! Consumes vLLM's per-round Anthropic SSE and presents the client **one**
4//! logical message across all gateway rounds:
5//!   * `message_start` emitted once (first round only);
6//!   * surfaced `content_block_*` forwarded with client-visible indices rebased
7//!     contiguously across rounds;
8//!   * gateway-owned `tool_use` blocks suppressed (and their `input_json_delta`
9//!     buffered to reconstruct the call for dispatch);
10//!   * intermediate `message_delta`/`message_stop` (the per-round terminals)
11//!     suppressed; the final round's terminal is forwarded once.
12//!
13//! Structurally the Anthropic-native analogue of the Responses `GatewayStreamAccumulator`
14//! (#119/#132); kept deliberately parallel for a future consolidation. Reuses
15//! only the neutral tool layer via [`crate::types::messages::tool_seam`].
16
17use std::collections::{BTreeMap, HashMap, HashSet};
18use std::sync::Arc;
19use std::time::Duration;
20
21use async_stream::stream;
22use futures::StreamExt;
23use serde_json::{Value, json};
24
25use crate::executor::inference::{BoxStream, call_inference};
26use crate::executor::messages_request::{normalize_native_web_search, web_search_budget_exhausted_result};
27use crate::executor::request::ExecutionContext;
28use crate::tool::ToolRegistry;
29use crate::types::messages::tool_seam;
30use crate::utils::common::serialize_to_string;
31
32// Shared with the non-streaming loop so the two Messages loops can't drift.
33use crate::executor::messages_loop::{GATEWAY_TOOL_TIMEOUT, MAX_GATEWAY_TOOL_ROUNDS};
34/// vLLM streaming chunk timeout (per line). Generous — the loop's own budget is
35/// the round cap, not this.
36const CHUNK_TIMEOUT: Duration = Duration::from_secs(120);
37
38/// Drive the streaming Messages-native loop, yielding Anthropic SSE lines for
39/// the client. Owns the multi-round → single-message accumulation.
40#[must_use]
41pub fn run_messages_stream(
42    mut request: Value,
43    registry: Arc<ToolRegistry>,
44    exec_ctx: Arc<ExecutionContext>,
45    auth: Option<String>,
46) -> BoxStream {
47    let url = format!("{}/v1/messages", exec_ctx.llm_base_url);
48    let preparation = normalize_native_web_search(&mut request);
49    request["stream"] = Value::Bool(true);
50
51    Box::pin(stream! {
52        let mut web_search_budget = match preparation {
53            Ok(budget) => budget,
54            Err(error) => { yield error_sse(&error.to_string()); return; }
55        };
56        let mut acc = MessagesStreamAccumulator::new(exec_ctx.messages_gateway_tools.clone());
57
58        for _round in 0..MAX_GATEWAY_TOOL_ROUNDS {
59            let body = match serialize_to_string(&request) {
60                Ok(b) => b,
61                Err(e) => { yield error_sse(&e.to_string()); return; }
62            };
63            let mut upstream = Box::pin(call_inference(
64                body, url.clone(), Arc::clone(&exec_ctx.client), auth.clone(), CHUNK_TIMEOUT,
65            ));
66
67            acc.begin_round();
68            while let Some(line) = upstream.next().await {
69                let line = match line {
70                    Ok(l) => l,
71                    Err(e) => { yield error_sse(&e.to_string()); return; }
72                };
73                for out in acc.push(&line) {
74                    yield out;
75                }
76            }
77
78            // Round finished. Continue only for a pure gateway-tool round; a
79            // client-owned tool_use (or any non-tool_use stop) is terminal.
80            if !acc.should_continue_loop() {
81                for out in acc.finish() {
82                    yield out;
83                }
84                return;
85            }
86            // Reconstruct the FULL assistant turn (thinking/text/signature +
87            // gateway tool_use, in order) for the next round's history — not just
88            // the gateway tool_use (F3, streaming half). The gateway calls are
89            // derived from the same buffered blocks for dispatch.
90            let (assistant_content, calls) = acc.take_round();
91            let allowed_searches = web_search_budget.reserve(calls.len());
92            let resolved = execute_gateway_calls(
93                &calls,
94                &registry,
95                &exec_ctx.messages_gateway_tools,
96                allowed_searches,
97            ).await;
98            append_round_to_history(&mut request, &assistant_content, &resolved);
99        }
100
101        // Round budget exhausted.
102        yield error_sse(&format!("gateway tool loop exceeded {MAX_GATEWAY_TOOL_ROUNDS} rounds"));
103    })
104}
105
106/// A gateway `tool_use` reconstructed from the stream, ready to dispatch.
107struct StreamedCall {
108    id: String,
109    name: String,
110    input_json: String,
111}
112
113/// One assistant content block buffered across a round, so the full turn
114/// (`thinking`/`text`/`signature`/`tool_use`, in order) can be reconstructed for
115/// the next round's history — F3. The client-facing SSE is still forwarded live;
116/// this is a parallel record for the fed-back conversation state.
117struct BufferedBlock {
118    /// The `content_block` skeleton from `content_block_start`, mutated by deltas.
119    block: Value,
120    /// Accumulated `input_json_delta` fragments for a `tool_use` block.
121    input_json: String,
122    /// Gateway-owned `tool_use` (drives the loop; suppressed from the client).
123    is_gateway_tool: bool,
124}
125
126impl BufferedBlock {
127    fn apply_delta(&mut self, delta: &Value) {
128        match delta.get("type").and_then(Value::as_str) {
129            Some("text_delta") => append_str(&mut self.block, "text", delta.get("text")),
130            Some("thinking_delta") => append_str(&mut self.block, "thinking", delta.get("thinking")),
131            Some("signature_delta") => append_str(&mut self.block, "signature", delta.get("signature")),
132            Some("input_json_delta") => {
133                if let Some(partial) = delta.get("partial_json").and_then(Value::as_str) {
134                    self.input_json.push_str(partial);
135                }
136            }
137            _ => {}
138        }
139    }
140
141    /// The finished assistant content block. For `tool_use`, parse the
142    /// accumulated arguments (best-effort — a malformed fragment falls back to
143    /// `{}`; the paired error `tool_result` records the failure).
144    fn to_block(&self) -> Value {
145        let mut block = self.block.clone();
146        if block.get("type").and_then(Value::as_str) == Some("tool_use") {
147            block["input"] = tool_seam::parse_tool_input(&self.input_json).unwrap_or_else(|_| json!({}));
148        }
149        block
150    }
151}
152
153/// Append a streamed string fragment onto a string field of `block`, creating it
154/// if absent.
155fn append_str(block: &mut Value, field: &str, fragment: Option<&Value>) {
156    let Some(fragment) = fragment.and_then(Value::as_str) else {
157        return;
158    };
159    let combined = match block.get(field).and_then(Value::as_str) {
160        Some(existing) => format!("{existing}{fragment}"),
161        None => fragment.to_owned(),
162    };
163    block[field] = Value::from(combined);
164}
165
166/// State machine that turns per-round Anthropic SSE into one client-visible
167/// message. Fed line-by-line via [`Self::push`].
168struct MessagesStreamAccumulator {
169    message_started: bool,
170    /// Next client-visible block index (contiguous across rounds).
171    next_index: u32,
172    /// Map upstream (per-round) block index → client index, for the blocks we
173    /// forward this round. Cleared each round.
174    index_map: HashMap<u64, u32>,
175    /// Upstream indices belonging to a suppressed gateway `tool_use` this round.
176    suppressed_indices: HashSet<u64>,
177    /// Every assistant block this round, keyed by upstream index (ordered), so
178    /// the full turn — `thinking`/`text`/`signature` + gateway `tool_use` — can
179    /// be reconstructed for the next round's history (F3). Cleared each round.
180    blocks: BTreeMap<u64, BufferedBlock>,
181    /// Did this round end with `stop_reason: tool_use`?
182    ended_on_tool_use: bool,
183    /// Did this round surface a client-owned `tool_use`? If so the loop cannot
184    /// continue server-side (the client must supply that tool's result), so it
185    /// is terminal — matching the non-streaming path's E7 handling.
186    has_client_tool_use: bool,
187    /// Buffered terminal `message_delta` from the final round (emitted by `finish`).
188    final_message_delta: Option<Value>,
189    /// Operator-configured client-tool → gateway-executor aliases, so a client
190    /// tool like Claude Code's `WebSearch` is classified gateway-owned (and
191    /// suppressed) the same way the built-in `web_search` is.
192    gateway_map: tool_seam::GatewayToolMap,
193}
194
195impl MessagesStreamAccumulator {
196    fn new(gateway_map: tool_seam::GatewayToolMap) -> Self {
197        Self {
198            message_started: false,
199            next_index: 0,
200            index_map: HashMap::new(),
201            suppressed_indices: HashSet::new(),
202            blocks: BTreeMap::new(),
203            ended_on_tool_use: false,
204            has_client_tool_use: false,
205            final_message_delta: None,
206            gateway_map,
207        }
208    }
209
210    fn begin_round(&mut self) {
211        self.index_map.clear();
212        self.suppressed_indices.clear();
213        self.blocks.clear();
214        self.ended_on_tool_use = false;
215        self.has_client_tool_use = false;
216        // F6: clear the previous round's terminal so a clean-EOF round can't
217        // re-emit a stale stop_reason.
218        self.final_message_delta = None;
219    }
220
221    /// Number of gateway `tool_use` blocks buffered this round.
222    fn gateway_call_count(&self) -> usize {
223        self.blocks.values().filter(|b| b.is_gateway_tool).count()
224    }
225
226    /// Consume this round's buffered blocks, returning (full assistant content in
227    /// order, gateway calls to dispatch). The assistant content preserves
228    /// `thinking`/`text`/`signature` and the gateway `tool_use` blocks (F3); the
229    /// calls are the gateway `tool_use` blocks reconstructed for dispatch.
230    fn take_round(&mut self) -> (Vec<Value>, Vec<StreamedCall>) {
231        let blocks = std::mem::take(&mut self.blocks);
232        let mut assistant_content = Vec::with_capacity(blocks.len());
233        let mut calls = Vec::new();
234        for buffered in blocks.values() {
235            assistant_content.push(buffered.to_block());
236            if buffered.is_gateway_tool {
237                calls.push(StreamedCall {
238                    id: buffered.block["id"].as_str().unwrap_or_default().to_owned(),
239                    name: buffered.block["name"].as_str().unwrap_or_default().to_owned(),
240                    input_json: buffered.input_json.clone(),
241                });
242            }
243        }
244        (assistant_content, calls)
245    }
246
247    /// The loop should continue only when the round asked for a gateway tool AND
248    /// did not also surface a client-owned tool (which the client must handle,
249    /// making the round terminal — E7).
250    fn should_continue_loop(&self) -> bool {
251        self.ended_on_tool_use && self.gateway_call_count() > 0 && !self.has_client_tool_use
252    }
253
254    /// Translate one upstream SSE line into zero or more client SSE lines.
255    fn push(&mut self, line: &str) -> Vec<String> {
256        let Some(data) = line.strip_prefix("data: ") else {
257            return Vec::new();
258        };
259        let data = data.trim();
260        if data == "[DONE]" {
261            return Vec::new();
262        }
263        let Ok(mut event) = serde_json::from_str::<Value>(data) else {
264            return Vec::new();
265        };
266        match event.get("type").and_then(Value::as_str) {
267            Some("message_start") => self.on_message_start(&event),
268            Some("content_block_start") => self.on_block_start(&mut event),
269            Some("content_block_delta") => self.on_block_delta(&mut event),
270            Some("content_block_stop") => self.on_block_stop(&mut event),
271            Some("message_delta") => {
272                // Buffer as the (possibly) final terminal; suppress mid-loop.
273                self.ended_on_tool_use = event["delta"]["stop_reason"].as_str() == Some("tool_use");
274                self.final_message_delta = Some(event);
275                Vec::new()
276            }
277            // `message_stop` (per-round terminal) is suppressed; `finish` emits
278            // the single client-visible terminal. Everything else is dropped.
279            _ => Vec::new(),
280        }
281    }
282
283    fn on_message_start(&mut self, event: &Value) -> Vec<String> {
284        if self.message_started {
285            return Vec::new();
286        }
287        self.message_started = true;
288        vec![sse("message_start", event)]
289    }
290
291    fn on_block_start(&mut self, event: &mut Value) -> Vec<String> {
292        let up_index = event.get("index").and_then(Value::as_u64).unwrap_or(0);
293        let block_type = event["content_block"]["type"].as_str().unwrap_or_default();
294        let name = event["content_block"]["name"].as_str().unwrap_or_default();
295
296        // Buffer every block for history reconstruction (F3), preserving order.
297        let is_gateway_tool = block_type == "tool_use" && self.gateway_map.is_gateway_owned(name);
298        self.blocks.insert(
299            up_index,
300            BufferedBlock {
301                block: event["content_block"].clone(),
302                input_json: String::new(),
303                is_gateway_tool,
304            },
305        );
306
307        if block_type == "tool_use" {
308            if is_gateway_tool {
309                // Suppress gateway-owned tool_use from the client; it stays in the
310                // buffered history only and drives the loop.
311                self.suppressed_indices.insert(up_index);
312                return Vec::new();
313            }
314            // A client-owned tool_use: the client must execute it, so this round
315            // is terminal (E7). Forward it (below) and stop the loop.
316            self.has_client_tool_use = true;
317        }
318
319        // Forward with a rebased contiguous client index.
320        let client_index = self.next_index;
321        self.next_index += 1;
322        self.index_map.insert(up_index, client_index);
323        event["index"] = Value::from(client_index);
324        vec![sse("content_block_start", event)]
325    }
326
327    fn on_block_delta(&mut self, event: &mut Value) -> Vec<String> {
328        let up_index = event.get("index").and_then(Value::as_u64).unwrap_or(0);
329        // Accumulate the delta into the buffered block (for history — F3),
330        // regardless of whether it is forwarded to the client.
331        if let Some(buffered) = self.blocks.get_mut(&up_index) {
332            buffered.apply_delta(&event["delta"]);
333        }
334        // A suppressed gateway tool_use is not forwarded to the client (its
335        // input_json_delta was just buffered above).
336        if self.suppressed_indices.contains(&up_index) {
337            return Vec::new();
338        }
339        let Some(&client_index) = self.index_map.get(&up_index) else {
340            return Vec::new();
341        };
342        event["index"] = Value::from(client_index);
343        vec![sse("content_block_delta", event)]
344    }
345
346    fn on_block_stop(&mut self, event: &mut Value) -> Vec<String> {
347        let up_index = event.get("index").and_then(Value::as_u64).unwrap_or(0);
348        if self.suppressed_indices.contains(&up_index) {
349            return Vec::new();
350        }
351        let Some(&client_index) = self.index_map.get(&up_index) else {
352            return Vec::new();
353        };
354        event["index"] = Value::from(client_index);
355        vec![sse("content_block_stop", event)]
356    }
357
358    /// Emit the terminal `message_delta` + `message_stop` once, at loop end.
359    fn finish(&mut self) -> Vec<String> {
360        let mut out = Vec::new();
361        if let Some(delta) = self.final_message_delta.take() {
362            out.push(sse("message_delta", &delta));
363        }
364        out.push(sse("message_stop", &json!({"type": "message_stop"})));
365        out
366    }
367}
368
369fn sse(event: &str, value: &Value) -> String {
370    let json = serialize_to_string(value).unwrap_or_default();
371    format!("event: {event}\ndata: {json}\n\n")
372}
373
374fn error_sse(message: &str) -> String {
375    let event = json!({"type": "error", "error": {"type": "api_error", "message": message}});
376    let json = serialize_to_string(&event).unwrap_or_default();
377    format!("event: error\ndata: {json}\n\n")
378}
379
380/// Execute reconstructed gateway calls (concurrent, per-call timeout). Errors
381/// become error `tool_result`s (E5).
382async fn execute_gateway_calls(
383    calls: &[StreamedCall],
384    registry: &ToolRegistry,
385    gateway_map: &tool_seam::GatewayToolMap,
386    allowed_searches: usize,
387) -> Vec<ResolvedStreamCall> {
388    let futures = calls.iter().enumerate().map(|(index, c)| async move {
389        if index >= allowed_searches {
390            return ResolvedStreamCall {
391                tool_result_block: web_search_budget_exhausted_result(&c.id),
392            };
393        }
394        // F4: reject a malformed/incomplete reconstructed input rather than
395        // coercing to {} and dispatching the tool with args the model never sent.
396        let (output, is_error) = match tool_seam::parse_tool_input(&c.input_json) {
397            Ok(input) => {
398                let call = tool_seam::tool_use_to_call(&c.id, &c.name, &input, gateway_map);
399                match tokio::time::timeout(GATEWAY_TOOL_TIMEOUT, registry.dispatch(&call)).await {
400                    Ok(Some(result)) => match result.output {
401                        Ok(o) => (o.output, false),
402                        Err(e) => (format!("tool execution failed: {e}"), true),
403                    },
404                    Ok(None) => (format!("no handler for tool '{}'", c.name), true),
405                    Err(_) => (
406                        format!("gateway tool '{}' timed out after {GATEWAY_TOOL_TIMEOUT:?}", c.name),
407                        true,
408                    ),
409                }
410            }
411            Err(reason) => (format!("{reason}; tool was not run"), true),
412        };
413        ResolvedStreamCall {
414            tool_result_block: tool_seam::tool_result_block(&c.id, &output, is_error),
415        }
416    });
417    futures::future::join_all(futures).await
418}
419
420/// The `tool_result` block for one executed gateway call, fed back next round.
421/// (The assistant turn — including this call's `tool_use` block — is reconstructed
422/// from the accumulator's buffered blocks in [`MessagesStreamAccumulator::take_round`].)
423struct ResolvedStreamCall {
424    tool_result_block: Value,
425}
426
427/// Append the model's full assistant turn (`thinking`/`text`/`signature` +
428/// gateway `tool_use`, order preserved — F3) and a following user turn of `tool_result`s,
429/// so the next upstream round sees the complete conversation state. These stay
430/// internal — the client never sees the gateway call (hide-the-call).
431fn append_round_to_history(request: &mut Value, assistant_content: &[Value], resolved: &[ResolvedStreamCall]) {
432    let assistant = json!({ "role": "assistant", "content": assistant_content });
433    let user = json!({
434        "role": "user",
435        "content": resolved.iter().map(|r| r.tool_result_block.clone()).collect::<Vec<_>>()
436    });
437    if let Some(messages) = request.get_mut("messages").and_then(Value::as_array_mut) {
438        messages.push(assistant);
439        messages.push(user);
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    fn line(v: &Value) -> String {
448        format!("data: {v}")
449    }
450
451    /// Accumulator with the default gateway map (built-in `web_search` only).
452    fn acc() -> MessagesStreamAccumulator {
453        MessagesStreamAccumulator::new(tool_seam::GatewayToolMap::default())
454    }
455
456    // A single non-tool round: message_start forwarded once, blocks pass through
457    // with contiguous indices, terminal emitted by finish().
458    #[test]
459    fn single_round_text_passes_through() {
460        let mut acc = acc();
461        acc.begin_round();
462        let mut out = Vec::new();
463        out.extend(acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}}))));
464        out.extend(acc.push(&line(
465            &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
466        )));
467        out.extend(acc.push(&line(
468            &json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}}),
469        )));
470        out.extend(acc.push(&line(&json!({"type": "content_block_stop", "index": 0}))));
471        out.extend(acc.push(&line(
472            &json!({"type": "message_delta", "delta": {"stop_reason": "end_turn"}}),
473        )));
474        out.extend(acc.push(&line(&json!({"type": "message_stop"}))));
475        assert!(!acc.should_continue_loop(), "text-only round is terminal");
476        out.extend(acc.finish());
477        let s = out.join("");
478        assert_eq!(s.matches("event: message_start").count(), 1);
479        assert_eq!(s.matches("event: message_stop").count(), 1);
480        assert!(s.contains("text_delta"));
481        assert!(s.contains("end_turn"));
482    }
483
484    // A gateway tool round: the tool_use block (start/delta/stop) is suppressed,
485    // its input reconstructed, thinking/text forwarded, and no terminal leaks.
486    #[test]
487    fn gateway_tool_round_suppresses_tool_use_and_reconstructs_call() {
488        let mut acc = acc();
489        acc.begin_round();
490        let mut out = Vec::new();
491        out.extend(acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}}))));
492        // thinking idx0 (forward)
493        out.extend(acc.push(&line(
494            &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}}),
495        )));
496        out.extend(acc.push(&line(&json!({"type": "content_block_stop", "index": 0}))));
497        // gateway tool_use idx1 (suppress + reconstruct)
498        out.extend(acc.push(&line(&json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "tid", "name": "web_search", "input": {}}}))));
499        out.extend(acc.push(&line(&json!({"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "{\"query\":"}}))));
500        out.extend(acc.push(&line(&json!({"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "\"rust\"}"}}))));
501        out.extend(acc.push(&line(&json!({"type": "content_block_stop", "index": 1}))));
502        out.extend(acc.push(&line(
503            &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
504        )));
505        out.extend(acc.push(&line(&json!({"type": "message_stop"}))));
506
507        let s = out.join("");
508        assert!(acc.should_continue_loop(), "pure gateway-tool round continues the loop");
509        assert!(!s.contains("tool_use"), "gateway tool_use must not surface: {s}");
510        assert!(!s.contains("message_stop"), "intermediate terminal suppressed");
511        assert!(s.contains("thinking"), "thinking forwarded");
512        let (_assistant, calls) = acc.take_round();
513        assert_eq!(calls.len(), 1);
514        assert_eq!(calls[0].name, "web_search");
515        assert_eq!(calls[0].input_json, "{\"query\":\"rust\"}");
516    }
517
518    // Across two rounds, client-visible block indices stay contiguous (round 1
519    // thinking=0, round 2 text=1) — no reset/collision.
520    #[test]
521    fn indices_are_contiguous_across_rounds() {
522        let mut acc = acc();
523        // round 1: thinking (idx0) + suppressed tool_use (idx1)
524        acc.begin_round();
525        acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
526        acc.push(&line(
527            &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking"}}),
528        ));
529        acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
530        acc.push(&line(&json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "name": "web_search", "id": "t"}})));
531        acc.push(&line(&json!({"type": "content_block_stop", "index": 1})));
532        // round 2: text (upstream idx0) must map to client idx1
533        acc.begin_round();
534        let out = acc.push(&line(
535            &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}),
536        ));
537        let started: Value =
538            serde_json::from_str(out[0].lines().nth(1).unwrap().strip_prefix("data: ").unwrap()).unwrap();
539        assert_eq!(started["index"], 1, "round-2 text rebased to contiguous client index 1");
540    }
541
542    // E7 (streaming): a round with a gateway tool_use AND a client-owned tool_use
543    // is terminal — the loop must NOT continue (the client owns the second tool).
544    // The client-owned tool_use is forwarded; the gateway one is suppressed.
545    #[test]
546    fn mixed_client_and_gateway_tool_use_stops_the_loop() {
547        let mut acc = acc();
548        acc.begin_round();
549        acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
550        // gateway tool_use (idx0) — suppressed
551        acc.push(&line(&json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "name": "web_search", "id": "g"}})));
552        acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
553        // client tool_use (idx1) — forwarded
554        let out = acc.push(&line(&json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "name": "get_weather", "id": "c"}})));
555        acc.push(&line(&json!({"type": "content_block_stop", "index": 1})));
556        acc.push(&line(
557            &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
558        ));
559
560        // Client tool_use surfaces; gateway one does not.
561        let started: Value =
562            serde_json::from_str(out[0].lines().nth(1).unwrap().strip_prefix("data: ").unwrap()).unwrap();
563        assert_eq!(
564            started["content_block"]["name"], "get_weather",
565            "client tool_use forwarded"
566        );
567        // The loop must terminate despite a gateway call being present.
568        assert!(
569            !acc.should_continue_loop(),
570            "mixed round is terminal — loop must not continue"
571        );
572    }
573
574    // F6 (repro): begin_round() must reset final_message_delta. Round 1 ends on a
575    // tool_use terminal; round 2 ends WITHOUT a message_delta (clean EOF). finish()
576    // must NOT emit round 1's stale stop_reason: tool_use.
577    #[test]
578    fn repro_f6_begin_round_resets_stale_terminal() {
579        let mut acc = acc();
580        // Round 1: a gateway tool round → sets final_message_delta = tool_use terminal.
581        acc.begin_round();
582        acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
583        acc.push(&line(&json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "name": "web_search", "id": "t"}})));
584        acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
585        acc.push(&line(
586            &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
587        ));
588        // Round 2: text, but upstream ends with NO message_delta (cut short).
589        acc.begin_round();
590        acc.push(&line(
591            &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}),
592        ));
593        acc.push(&line(
594            &json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hi"}}),
595        ));
596        acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
597        let out = acc.finish().join("");
598        assert!(
599            !out.contains(r#""stop_reason":"tool_use""#),
600            "must not emit round 1's stale tool_use terminal: {out}"
601        );
602    }
603
604    // F3 (repro): the assistant turn fed into the next round's history must
605    // preserve the model's thinking/text/signature blocks in order, not just the
606    // gateway tool_use. (This is the streaming half of Maral's F3 — "also repeated
607    // in messages_stream.rs".)
608    #[test]
609    fn repro_f3_stream_history_preserves_thinking_text_and_signature() {
610        let mut acc = acc();
611        acc.begin_round();
612        acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
613        // thinking idx0 (with a signature delta)
614        acc.push(&line(
615            &json!({"type": "content_block_start", "index": 0, "content_block": {"type": "thinking", "thinking": ""}}),
616        ));
617        acc.push(&line(&json!({"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "let me search"}})));
618        acc.push(&line(&json!({"type": "content_block_delta", "index": 0, "delta": {"type": "signature_delta", "signature": "SIG=="}})));
619        acc.push(&line(&json!({"type": "content_block_stop", "index": 0})));
620        // text idx1
621        acc.push(&line(
622            &json!({"type": "content_block_start", "index": 1, "content_block": {"type": "text", "text": ""}}),
623        ));
624        acc.push(&line(&json!({"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "Searching..."}})));
625        acc.push(&line(&json!({"type": "content_block_stop", "index": 1})));
626        // gateway tool_use idx2 (suppressed from client, but must appear in history)
627        acc.push(&line(&json!({"type": "content_block_start", "index": 2, "content_block": {"type": "tool_use", "id": "tid", "name": "web_search", "input": {}}})));
628        acc.push(&line(&json!({"type": "content_block_delta", "index": 2, "delta": {"type": "input_json_delta", "partial_json": "{\"query\":\"rust\"}"}})));
629        acc.push(&line(&json!({"type": "content_block_stop", "index": 2})));
630        acc.push(&line(
631            &json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
632        ));
633
634        let (assistant, _calls) = acc.take_round();
635        let types: Vec<&str> = assistant.iter().filter_map(|b| b["type"].as_str()).collect();
636        assert_eq!(
637            types,
638            vec!["thinking", "text", "tool_use"],
639            "full assistant turn preserved in order, not just the gateway tool_use: {assistant:?}"
640        );
641        assert_eq!(assistant[0]["thinking"], "let me search", "thinking text reconstructed");
642        assert_eq!(
643            assistant[0]["signature"], "SIG==",
644            "signature preserved for the next round"
645        );
646        assert_eq!(assistant[1]["text"], "Searching...", "text reconstructed");
647        assert_eq!(
648            assistant[2]["input"]["query"], "rust",
649            "gateway call input reconstructed"
650        );
651    }
652
653    // F4 (repro): a malformed/incomplete input_json for a gateway call must NOT
654    // silently become `{}` and dispatch the tool with args the model never sent.
655    #[tokio::test]
656    async fn repro_f4_malformed_partial_json_is_not_dispatched_with_empty_args() {
657        let mut acc = acc();
658        acc.begin_round();
659        acc.push(&line(&json!({"type": "message_start", "message": {"id": "m"}})));
660        acc.push(&line(&json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "name": "web_search", "id": "t"}})));
661        // Incomplete partial_json (stream cut mid-arguments).
662        acc.push(&line(&json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"query\":"}})));
663        let (_assistant, calls) = acc.take_round();
664        assert_eq!(calls.len(), 1);
665        // The reconstructed input is invalid JSON.
666        assert!(
667            serde_json::from_str::<serde_json::Value>(&calls[0].input_json).is_err(),
668            "incomplete partial_json is invalid JSON"
669        );
670        // After the fix, execute_gateway_calls must NOT coerce invalid input to
671        // {} and dispatch — it must produce an error tool_result. Assert the
672        // reconstructed call is flagged invalid rather than silently dispatchable.
673        let resolved = execute_gateway_calls(
674            &calls,
675            &no_op_registry().await,
676            &tool_seam::GatewayToolMap::default(),
677            usize::MAX,
678        )
679        .await;
680        let content = resolved[0].tool_result_block["content"].as_str().unwrap_or_default();
681        assert!(
682            content.contains("invalid") || content.contains("malformed") || content.contains("could not"),
683            "malformed args must yield an error tool_result, not an empty-arg dispatch: {content:?}"
684        );
685    }
686
687    /// Registry with no gateway executors — dispatch of any call returns None, so
688    /// the ONLY way `execute_gateway_calls` can produce a non-"no handler" result
689    /// for a malformed input is by rejecting the args before dispatch (the fix).
690    async fn no_op_registry() -> ToolRegistry {
691        let mut tools = [];
692        let mut executors = crate::tool::GatewayExecutors::from_env(std::sync::Arc::new(reqwest::Client::new()));
693        ToolRegistry::build_with_handlers(&mut tools, &mut executors)
694            .await
695            .unwrap()
696    }
697}