Skip to main content

scx_forge_agent/
api.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! OpenAI-compatible `/chat/completions` client with a tool-calling loop.
4//!
5//! Builds the request body, applies bearer auth, parses the first choice, and
6//! when the assistant returns `tool_calls` runs them via
7//! [`crate::tools::execute_tool`] and feeds the results back until it returns plain
8//! content. Prompt-cache markers and token-budget trimming are intentionally
9//! omitted for this first cut.
10
11use std::collections::{BTreeMap, HashSet};
12use std::io::{IsTerminal, Write};
13use std::path::{Path, PathBuf};
14use std::sync::{atomic::AtomicBool, Arc};
15use std::time::{Duration, Instant};
16use std::{error::Error, fmt};
17
18use anyhow::{anyhow, Context, Result};
19use serde_json::{json, Value};
20
21use crate::color::Style;
22use crate::config::ModelConfig;
23use crate::interrupt;
24use crate::model_timeout::ModelTurnDeadline;
25use crate::tools;
26
27/// A stderr spinner shown until the first streamed response chunk arrives.
28/// Active only on a TTY; a no-op otherwise so logs and redirected output stay clean.
29struct Spinner {
30    handle: Option<tokio::task::JoinHandle<()>>,
31}
32
33impl Spinner {
34    fn start(label: &str, color: Style) -> Spinner {
35        if !std::io::stderr().is_terminal() {
36            return Spinner { handle: None };
37        }
38        let label = label.to_string();
39        let handle = tokio::spawn(async move {
40            let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
41            let mut i = 0usize;
42            loop {
43                eprint!(
44                    "\r{} {}",
45                    color.dim(frames[i % frames.len()]),
46                    color.dim(&label)
47                );
48                let _ = std::io::stderr().flush();
49                i += 1;
50                tokio::time::sleep(Duration::from_millis(80)).await;
51            }
52        });
53        Spinner {
54            handle: Some(handle),
55        }
56    }
57
58    fn stop(&mut self) {
59        if let Some(h) = self.handle.take() {
60            h.abort();
61            eprint!("\r\x1b[K");
62            let _ = std::io::stderr().flush();
63        }
64    }
65}
66
67impl Drop for Spinner {
68    fn drop(&mut self) {
69        self.stop();
70    }
71}
72
73/// Build the system message. With `cache=true` the content is an Anthropic-style
74/// block carrying `cache_control: ephemeral` so the (large, constant) system
75/// prompt can be cached across requests; with `cache=false` it's a plain string,
76/// byte-identical to a no-caching request.
77fn system_message(system: &str, cache: bool) -> Value {
78    if cache {
79        json!({
80            "role": "system",
81            "content": [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
82        })
83    } else {
84        json!({"role": "system", "content": system})
85    }
86}
87
88fn path_with_line_range(path: &str, v: &Value) -> String {
89    let start = v.get("start_line").and_then(|x| x.as_u64());
90    let end = v.get("end_line").and_then(|x| x.as_u64());
91    match (start, end) {
92        (None, None) => format!("{path}:1-EOF"),
93        (Some(start), None) => format!("{path}:{start}-EOF"),
94        (None, Some(end)) => format!("{path}:1-{end}"),
95        (Some(start), Some(end)) => format!("{path}:{start}-{end}"),
96    }
97}
98
99/// A short, human-friendly label for a tool call (shown in non-verbose output):
100/// prefers the most salient argument (`path`/`pattern`) over the raw JSON.
101fn tool_call_label(name: &str, args: &str) -> String {
102    let hint = serde_json::from_str::<Value>(args)
103        .ok()
104        .and_then(|v| {
105            let scheduler = v.get("scheduler").and_then(|x| x.as_str());
106            if name == "read_file" {
107                if let Some(path) = v.get("path").and_then(|x| x.as_str()) {
108                    return Some(path_with_line_range(path, &v));
109                }
110            }
111            if name == "read_scheduler_file" {
112                if let (Some(scheduler), Some(path)) =
113                    (scheduler, v.get("path").and_then(|x| x.as_str()))
114                {
115                    return Some(format!("{scheduler}/{}", path_with_line_range(path, &v)));
116                }
117            }
118            if name == "grep_schedulers" {
119                if let (Some(scheduler), Some(pattern)) =
120                    (scheduler, v.get("pattern").and_then(|x| x.as_str()))
121                {
122                    return Some(format!("{scheduler}:{pattern}"));
123                }
124            }
125            v.get("path")
126                .or_else(|| v.get("pattern"))
127                .or_else(|| v.get("url"))
128                .and_then(|x| x.as_str())
129                .map(|s| s.to_string())
130        })
131        .unwrap_or_else(|| preview(args, 80).replace('\n', " "));
132    format!("{name}({hint})")
133}
134
135/// Read/edit sandbox + iteration cap for the tool loop.
136pub struct ToolLoopConfig {
137    pub sandbox: PathBuf,
138    pub scheds_root: Option<PathBuf>,
139    pub allow_edit: bool,
140    pub max_iterations: u32,
141}
142
143/// HTTP status failure returned by the OpenAI-compatible endpoint.
144#[derive(Debug)]
145pub struct ApiStatusError {
146    status: reqwest::StatusCode,
147    body: String,
148}
149
150impl fmt::Display for ApiStatusError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "API error {}: {}", self.status, self.body)
153    }
154}
155
156impl Error for ApiStatusError {}
157
158/// True if an error came from the provider/request layer rather than local tool
159/// execution or response parsing. The controller can skip a round for these.
160pub fn is_api_error(err: &anyhow::Error) -> bool {
161    err.downcast_ref::<ApiStatusError>().is_some()
162        || err
163            .chain()
164            .any(|cause| cause.downcast_ref::<reqwest::Error>().is_some())
165}
166
167/// One-line provider/request error for logs and reports.
168pub fn error_summary(err: &anyhow::Error) -> String {
169    let raw = err
170        .downcast_ref::<ApiStatusError>()
171        .map(|e| e.to_string())
172        .unwrap_or_else(|| err.to_string());
173    let one_line = raw.split_whitespace().collect::<Vec<_>>().join(" ");
174    const MAX: usize = 500;
175    if one_line.chars().count() > MAX {
176        format!("{}...", one_line.chars().take(MAX).collect::<String>())
177    } else {
178        one_line
179    }
180}
181
182fn apply_bearer(req: reqwest::RequestBuilder, api_key: &str) -> reqwest::RequestBuilder {
183    if api_key.is_empty() {
184        req
185    } else {
186        req.header("Authorization", format!("Bearer {api_key}"))
187    }
188}
189
190/// `arguments` may arrive as a JSON string (OpenAI) or an inline object; normalize to a string.
191fn arguments_to_string(v: &Value) -> String {
192    match v {
193        Value::String(s) => s.clone(),
194        other => other.to_string(),
195    }
196}
197
198/// OpenAI-compatible chat history requires `function.arguments` to be a string.
199fn normalize_tool_call_arguments(tc: &mut Value) {
200    let Some(func) = tc.get_mut("function").and_then(|f| f.as_object_mut()) else {
201        return;
202    };
203    let args = func
204        .get("arguments")
205        .map(arguments_to_string)
206        .unwrap_or_else(|| "{}".to_string());
207    func.insert("arguments".to_string(), Value::String(args));
208}
209
210fn normalize_tool_calls(mut calls: Vec<Value>) -> Vec<Value> {
211    for tc in &mut calls {
212        normalize_tool_call_arguments(tc);
213    }
214    calls
215}
216
217/// Return a copy of `calls` safe to store in the chat history that gets re-sent
218/// on the next request.
219///
220/// OpenAI-compatible endpoints reject a tool_call whose `function.arguments` is
221/// not valid JSON. A model can emit a truncated/garbled call (e.g. an
222/// unterminated string when its output is cut off), and re-sending that verbatim
223/// makes the *next* request fail with a provider 400 - turning a recoverable
224/// tool error into a dead end for the whole turn. Replace any unparseable
225/// `arguments` with `{}` so the history stays well-formed. The original
226/// (malformed) call is still executed, so the model gets the informative
227/// "arguments are not valid JSON" tool result and can retry.
228fn sanitize_tool_calls_for_history(calls: &[Value]) -> Vec<Value> {
229    calls
230        .iter()
231        .map(|tc| {
232            let mut tc = tc.clone();
233            if let Some(func) = tc.get_mut("function").and_then(|f| f.as_object_mut()) {
234                let valid = func
235                    .get("arguments")
236                    .and_then(|a| a.as_str())
237                    .is_some_and(|s| serde_json::from_str::<Value>(s).is_ok());
238                if !valid {
239                    func.insert("arguments".to_string(), Value::String("{}".to_string()));
240                }
241            }
242            tc
243        })
244        .collect()
245}
246
247fn message_content_to_string(message: &Value) -> String {
248    match message.get("content") {
249        Some(Value::String(s)) => s.clone(),
250        Some(Value::Array(parts)) => parts
251            .iter()
252            .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
253            .collect::<Vec<_>>()
254            .join(""),
255        _ => String::new(),
256    }
257}
258
259/// Max characters of any single field printed in a verbose transcript dump.
260const DUMP_MAX_CHARS: usize = 8000;
261const SLOW_TOOL_LOG_AFTER: Duration = Duration::from_millis(500);
262
263/// Truncate `s` to `max` chars with an elision marker, for verbose dumps.
264fn preview(s: &str, max: usize) -> String {
265    let n = s.chars().count();
266    if n > max {
267        let head: String = s.chars().take(max).collect();
268        format!("{head}\n    [... {} more chars elided ...]", n - max)
269    } else {
270        s.to_string()
271    }
272}
273
274const TOOL_CALL_OPEN: &str = "<tool_call>";
275const TOOL_CALL_CLOSE: &str = "</tool_call>";
276
277/// Parse one `<tool_call>` block body (`{"name":..., "arguments"/"parameters":...}`)
278/// into a synthetic OpenAI-shaped tool call, or None if it isn't a valid call.
279fn parse_one_text_call(inner: &str, idx: usize) -> Option<Value> {
280    let v: Value = serde_json::from_str(inner.trim()).ok()?;
281    let name = v.get("name").and_then(|n| n.as_str())?;
282    let args = v
283        .get("arguments")
284        .or_else(|| v.get("parameters"))
285        .cloned()
286        .unwrap_or_else(|| json!({}));
287    Some(json!({
288        "id": format!("text_call_{idx}"),
289        "type": "function",
290        "function": {"name": name, "arguments": arguments_to_string(&args)},
291    }))
292}
293
294/// Fallback for models that emit tool calls as `<tool_call>{...}</tool_call>` text
295/// in the message content instead of the structured `tool_calls` field (the
296/// Hermes/Qwen convention). Returns the parsed calls (in OpenAI shape) plus the
297/// content with the blocks stripped out.
298fn parse_text_tool_calls(content: &str) -> (Vec<Value>, String) {
299    let mut calls = Vec::new();
300    let mut stripped = String::new();
301    let mut rest = content;
302    let mut idx = 0usize;
303    while let Some(start) = rest.find(TOOL_CALL_OPEN) {
304        stripped.push_str(&rest[..start]);
305        let after = &rest[start + TOOL_CALL_OPEN.len()..];
306        let (inner, consumed) = match after.find(TOOL_CALL_CLOSE) {
307            Some(end) => (
308                &after[..end],
309                start + TOOL_CALL_OPEN.len() + end + TOOL_CALL_CLOSE.len(),
310            ),
311            None => (after, rest.len()),
312        };
313        if let Some(call) = parse_one_text_call(inner, idx) {
314            calls.push(call);
315            idx += 1;
316        }
317        rest = &rest[consumed..];
318    }
319    stripped.push_str(rest);
320    (calls, stripped.trim().to_string())
321}
322
323#[derive(Default)]
324struct PendingToolCall {
325    id: String,
326    ty: String,
327    name: String,
328    arguments: String,
329}
330
331#[derive(Default)]
332struct StreamedChat {
333    content: String,
334    tool_calls: BTreeMap<usize, PendingToolCall>,
335    usage: Option<crate::usage::Usage>,
336    raw_events: String,
337}
338
339impl StreamedChat {
340    fn into_message(self) -> Value {
341        let content = if self.content.is_empty() {
342            Value::Null
343        } else {
344            Value::String(self.content)
345        };
346        let mut message = json!({
347            "role": "assistant",
348            "content": content,
349        });
350        if !self.tool_calls.is_empty() {
351            let calls: Vec<Value> = self
352                .tool_calls
353                .into_iter()
354                .map(|(idx, call)| {
355                    json!({
356                        "id": if call.id.is_empty() { format!("stream_call_{idx}") } else { call.id },
357                        "type": if call.ty.is_empty() { "function".to_string() } else { call.ty },
358                        "function": {
359                            "name": call.name,
360                            "arguments": if call.arguments.is_empty() { "{}".to_string() } else { call.arguments },
361                        },
362                    })
363                })
364                .collect();
365            message.as_object_mut().unwrap().insert(
366                "tool_calls".to_string(),
367                Value::Array(normalize_tool_calls(calls)),
368            );
369        }
370        message
371    }
372}
373
374enum StreamPrintState {
375    Visible,
376    InToolCall,
377}
378
379struct StreamTextPrinter {
380    enabled: bool,
381    started: bool,
382    saw_tool_call: bool,
383    last_was_newline: bool,
384    state: StreamPrintState,
385    buf: String,
386}
387
388fn floor_char_boundary(s: &str, idx: usize) -> usize {
389    let mut idx = idx.min(s.len());
390    while idx > 0 && !s.is_char_boundary(idx) {
391        idx -= 1;
392    }
393    idx
394}
395
396impl StreamTextPrinter {
397    fn new(enabled: bool) -> Self {
398        Self {
399            enabled,
400            started: false,
401            saw_tool_call: false,
402            last_was_newline: false,
403            state: StreamPrintState::Visible,
404            buf: String::new(),
405        }
406    }
407
408    fn emit(&mut self, text: &str) -> Result<()> {
409        if text.is_empty() {
410            return Ok(());
411        }
412        self.started = true;
413        print!("{text}");
414        std::io::stdout()
415            .flush()
416            .context("flush streamed assistant text")?;
417        self.last_was_newline = text.ends_with('\n');
418        Ok(())
419    }
420
421    fn note_tool_call(&mut self) {
422        self.saw_tool_call = true;
423    }
424
425    fn emit_before_tool_call(&mut self, text: &str) -> Result<()> {
426        let visible = text.trim_end();
427        if !visible.is_empty() {
428            self.emit(visible)?;
429        }
430        Ok(())
431    }
432
433    fn push(&mut self, text: &str) -> Result<()> {
434        if !self.enabled {
435            return Ok(());
436        }
437        self.buf.push_str(text);
438        loop {
439            match self.state {
440                StreamPrintState::Visible => {
441                    if let Some(pos) = self.buf.find(TOOL_CALL_OPEN) {
442                        let visible = self.buf[..pos].to_string();
443                        self.note_tool_call();
444                        self.emit_before_tool_call(&visible)?;
445                        self.buf.drain(..pos + TOOL_CALL_OPEN.len());
446                        self.state = StreamPrintState::InToolCall;
447                    } else {
448                        let keep = TOOL_CALL_OPEN.len().saturating_sub(1);
449                        let emit_len = self.buf.len().saturating_sub(keep);
450                        if emit_len == 0 {
451                            break;
452                        }
453                        let emit_len = floor_char_boundary(&self.buf, emit_len);
454                        if emit_len == 0 {
455                            break;
456                        }
457                        let visible = self.buf[..emit_len].to_string();
458                        if self.started || !visible.trim().is_empty() {
459                            self.emit(&visible)?;
460                        }
461                        self.buf.drain(..emit_len);
462                        break;
463                    }
464                }
465                StreamPrintState::InToolCall => {
466                    if let Some(pos) = self.buf.find(TOOL_CALL_CLOSE) {
467                        self.buf.drain(..pos + TOOL_CALL_CLOSE.len());
468                        self.state = StreamPrintState::Visible;
469                    } else {
470                        let keep = TOOL_CALL_CLOSE.len().saturating_sub(1);
471                        if self.buf.len() > keep {
472                            let drop_len = self.buf.len() - keep;
473                            let drop_len = floor_char_boundary(&self.buf, drop_len);
474                            if drop_len > 0 {
475                                self.buf.drain(..drop_len);
476                            }
477                        }
478                        break;
479                    }
480                }
481            }
482        }
483        Ok(())
484    }
485
486    fn finish(&mut self) -> Result<()> {
487        if !self.enabled {
488            return Ok(());
489        }
490        if matches!(self.state, StreamPrintState::Visible) && !self.buf.is_empty() {
491            let visible = std::mem::take(&mut self.buf);
492            if !visible.trim().is_empty() {
493                if self.saw_tool_call {
494                    self.emit(visible.trim_end())?;
495                } else {
496                    self.emit(&visible)?;
497                }
498            }
499        }
500        if self.started && !self.last_was_newline {
501            println!();
502        }
503        Ok(())
504    }
505}
506
507fn append_json_text(dst: &mut String, v: &Value) {
508    match v {
509        Value::String(s) => dst.push_str(s),
510        Value::Null => {}
511        other => dst.push_str(&other.to_string()),
512    }
513}
514
515fn apply_stream_delta(delta: &Value, streamed: &mut StreamedChat) -> Result<Option<String>> {
516    let mut visible = String::new();
517    if let Some(content) = delta.get("content") {
518        if let Some(s) = content.as_str() {
519            streamed.content.push_str(s);
520            visible.push_str(s);
521        }
522    }
523
524    if let Some(calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
525        for (fallback_idx, tc) in calls.iter().enumerate() {
526            let idx = tc
527                .get("index")
528                .and_then(|v| v.as_u64())
529                .and_then(|n| usize::try_from(n).ok())
530                .unwrap_or(fallback_idx);
531            let entry = streamed.tool_calls.entry(idx).or_default();
532            if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
533                if entry.id.is_empty() {
534                    entry.id = id.to_string();
535                } else if entry.id != id {
536                    entry.id.push_str(id);
537                }
538            }
539            if let Some(ty) = tc.get("type").and_then(|v| v.as_str()) {
540                if entry.ty.is_empty() {
541                    entry.ty = ty.to_string();
542                } else if entry.ty != ty {
543                    entry.ty.push_str(ty);
544                }
545            }
546            if let Some(func) = tc.get("function") {
547                if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
548                    entry.name.push_str(name);
549                }
550                if let Some(args) = func.get("arguments") {
551                    append_json_text(&mut entry.arguments, args);
552                }
553            }
554        }
555    }
556
557    if let Some(func) = delta.get("function_call") {
558        let entry = streamed.tool_calls.entry(0).or_default();
559        if entry.id.is_empty() {
560            entry.id = "stream_call_0".to_string();
561        }
562        if entry.ty.is_empty() {
563            entry.ty = "function".to_string();
564        }
565        if let Some(name) = func.get("name").and_then(|v| v.as_str()) {
566            entry.name.push_str(name);
567        }
568        if let Some(args) = func.get("arguments") {
569            append_json_text(&mut entry.arguments, args);
570        }
571    }
572
573    Ok((!visible.is_empty()).then_some(visible))
574}
575
576fn find_sse_delimiter(buf: &[u8]) -> Option<(usize, usize)> {
577    let lf = buf.windows(2).position(|w| w == b"\n\n").map(|p| (p, 2));
578    let crlf = buf
579        .windows(4)
580        .position(|w| w == b"\r\n\r\n")
581        .map(|p| (p, 4));
582    match (lf, crlf) {
583        (Some(a), Some(b)) => Some(if a.0 <= b.0 { a } else { b }),
584        (Some(a), None) => Some(a),
585        (None, Some(b)) => Some(b),
586        (None, None) => None,
587    }
588}
589
590fn sse_data(event: &[u8]) -> Result<Option<String>> {
591    let text = std::str::from_utf8(event).context("parse streaming SSE as UTF-8")?;
592    let data = text
593        .lines()
594        .filter_map(|line| line.strip_prefix("data:").map(str::trim_start))
595        .collect::<Vec<_>>();
596    if data.is_empty() {
597        Ok(None)
598    } else {
599        Ok(Some(data.join("\n")))
600    }
601}
602
603async fn read_streamed_chat(
604    mut resp: reqwest::Response,
605    stream_stdout: bool,
606    spinner: &mut Spinner,
607) -> Result<StreamedChat> {
608    let mut streamed = StreamedChat::default();
609    let mut printer = StreamTextPrinter::new(stream_stdout);
610    let mut buf = Vec::<u8>::new();
611
612    while let Some(chunk) = resp.chunk().await.context("read streaming chat chunk")? {
613        buf.extend_from_slice(&chunk);
614        while let Some((pos, delim_len)) = find_sse_delimiter(&buf) {
615            let event = buf.drain(..pos).collect::<Vec<_>>();
616            buf.drain(..delim_len);
617            let Some(data) = sse_data(&event)? else {
618                continue;
619            };
620            if data.trim() == "[DONE]" {
621                printer.finish()?;
622                return Ok(streamed);
623            }
624            streamed.raw_events.push_str(&data);
625            streamed.raw_events.push('\n');
626            let parsed: Value = serde_json::from_str(&data)
627                .with_context(|| format!("parse streaming chat JSON event: {data}"))?;
628            if let Some(u) = parsed.get("usage").filter(|u| !u.is_null()) {
629                streamed.usage = Some(crate::usage::Usage::from_openai(u));
630            }
631            let Some(delta) = parsed
632                .get("choices")
633                .and_then(|c| c.as_array())
634                .and_then(|a| a.first())
635                .and_then(|c| c.get("delta"))
636            else {
637                continue;
638            };
639            let has_tool_delta =
640                delta.get("tool_calls").is_some() || delta.get("function_call").is_some();
641            if delta.get("content").is_some() {
642                spinner.stop();
643            }
644            if has_tool_delta {
645                printer.note_tool_call();
646            }
647            if let Some(text) = apply_stream_delta(delta, &mut streamed)? {
648                printer.push(&text)?;
649            }
650        }
651    }
652
653    printer.finish()?;
654    Ok(streamed)
655}
656
657/// Run a chat turn with an optional tool loop. Returns the final assistant text.
658/// Every request's token usage is summed into `usage` (the tool loop makes one
659/// request per iteration).
660pub async fn chat(
661    client: &reqwest::Client,
662    model: &ModelConfig,
663    system: &str,
664    user: &str,
665    tool_loop: Option<&ToolLoopConfig>,
666    verbose: bool,
667    color: Style,
668    stream_stdout: bool,
669    usage: &mut crate::usage::Usage,
670    turn_timeout: Duration,
671    interrupted: Arc<AtomicBool>,
672) -> Result<String> {
673    let deadline = ModelTurnDeadline::new(turn_timeout);
674    let url = format!("{}/chat/completions", model.base_url.trim_end_matches('/'));
675    // Try Anthropic-style prompt caching: mark the large constant system prompt
676    // with cache_control so it isn't reprocessed on every tool-loop iteration /
677    // round. Servers that don't support it usually ignore the field; one that
678    // rejects it triggers a one-time fallback to plain content (see below).
679    let mut use_cache = true;
680    let mut use_stream_options = true;
681    let mut messages: Vec<Value> = vec![
682        system_message(system, use_cache),
683        json!({"role": "user", "content": user}),
684    ];
685
686    if verbose {
687        eprintln!(
688            "\n{} {}",
689            color.dim("[api]"),
690            color.bold(format!(
691                "new turn (model={}, system prompt={} chars)",
692                model.model_id,
693                system.len()
694            ))
695        );
696        eprintln!(
697            "{} {}\n{}",
698            color.dim("[api]"),
699            color.blue("user:"),
700            preview(user, DUMP_MAX_CHARS)
701        );
702    }
703
704    let mut tool_iterations = 0u32;
705    let mut edits_applied = 0usize;
706    let mut disable_tools = false;
707    let mut force_edit = false;
708    let mut force_attempts = 0u32;
709    let mut request_n = 0u32;
710    // Read-only (name, args) pairs already executed this turn. A model that gets
711    // stuck re-reading the same location burns iterations and bloats the context
712    // (every iteration re-sends the whole conversation), so identical read-only
713    // calls are short-circuited instead of re-run.
714    let mut seen_tool_calls: HashSet<String> = HashSet::new();
715
716    loop {
717        deadline.check()?;
718        if interrupt::requested(&interrupted) {
719            return Err(interrupt::err());
720        }
721
722        // A forced-edit request is bounded: after MAX_FORCE_ATTEMPTS we give up
723        // and let the model reply with a summary, so we never loop forever on a
724        // model/endpoint that won't emit an edit_file call.
725        const MAX_FORCE_ATTEMPTS: u32 = 3;
726        if force_edit {
727            force_attempts += 1;
728            if force_attempts > MAX_FORCE_ATTEMPTS {
729                force_edit = false;
730                disable_tools = true;
731            }
732        }
733
734        let mut body = serde_json::Map::new();
735        body.insert("model".into(), json!(model.model_id));
736        body.insert("messages".into(), json!(&messages));
737        body.insert("stream".into(), json!(true));
738        if use_stream_options {
739            body.insert(
740                "stream_options".into(),
741                json!({
742                    "include_usage": true,
743                }),
744            );
745        }
746        if let Some(cfg) = tool_loop {
747            body.insert(
748                "tools".into(),
749                tools::openai_tools_json(cfg.allow_edit, cfg.scheds_root.is_some()),
750            );
751            // Force the edit when exploration is exhausted; otherwise let the
752            // model choose tools freely, or disable them once we want a summary.
753            let tool_choice = if force_edit && cfg.allow_edit {
754                json!({"type": "function", "function": {"name": "edit_file"}})
755            } else if disable_tools {
756                json!("none")
757            } else {
758                json!("auto")
759            };
760            body.insert("tool_choice".into(), tool_choice);
761        }
762        let body = Value::Object(body);
763
764        request_n += 1;
765        if verbose {
766            let tc = if tool_loop.is_none() {
767                ""
768            } else if force_edit {
769                " tool_choice=edit_file(forced)"
770            } else if disable_tools {
771                " tool_choice=none"
772            } else {
773                " tool_choice=auto"
774            };
775            eprintln!(
776                "{} {}",
777                color.dim("[api]"),
778                color.cyan(format!(
779                    "POST {url} (request #{request_n}, {} messages{tc})",
780                    messages.len()
781                ))
782            );
783        }
784
785        let mut spinner = Spinner::start("thinking...", color);
786        let resp = tokio::select! {
787            resp = apply_bearer(client.post(&url), &model.api_key).json(&body).send() => {
788                resp.context("POST chat/completions")?
789            }
790            _ = interrupt::wait(interrupted.clone()) => {
791                spinner.stop();
792                return Err(interrupt::err());
793            }
794            _ = tokio::time::sleep(deadline.remaining()) => {
795                spinner.stop();
796                return Err(deadline.timeout().into());
797            }
798        };
799        let status = resp.status();
800        if !status.is_success() {
801            spinner.stop();
802            let text = tokio::select! {
803                text = resp.text() => text.context("read chat/completions body")?,
804                _ = interrupt::wait(interrupted.clone()) => return Err(interrupt::err()),
805                _ = tokio::time::sleep(deadline.remaining()) => {
806                    return Err(deadline.timeout().into());
807                }
808            };
809            // A server that rejects the prompt-cache markers: silently drop them
810            // and retry once without caching, then stick to plain content.
811            if use_cache {
812                use_cache = false;
813                messages[0] = system_message(system, false);
814                if verbose {
815                    eprintln!(
816                        "{} {}",
817                        color.dim("[api]"),
818                        color.yellow(format!(
819                            "request rejected ({status}); retrying without prompt-cache markers"
820                        ))
821                    );
822                }
823                continue;
824            }
825            if use_stream_options
826                && (text.contains("stream_options") || text.contains("include_usage"))
827            {
828                use_stream_options = false;
829                if verbose {
830                    eprintln!(
831                        "{} {}",
832                        color.dim("[api]"),
833                        color.yellow("stream usage option rejected; retrying without it")
834                    );
835                }
836                continue;
837            }
838            return Err(ApiStatusError { status, body: text }.into());
839        }
840
841        let streamed = tokio::select! {
842            streamed = read_streamed_chat(resp, stream_stdout, &mut spinner) => streamed?,
843            _ = interrupt::wait(interrupted.clone()) => {
844                spinner.stop();
845                return Err(interrupt::err());
846            }
847            _ = tokio::time::sleep(deadline.remaining()) => {
848                spinner.stop();
849                return Err(deadline.timeout().into());
850            }
851        };
852        spinner.stop();
853        let raw_events = streamed.raw_events.clone();
854        let streamed_usage = streamed.usage;
855        let message = streamed.into_message();
856
857        if verbose {
858            eprintln!(
859                "{} {}\n{}",
860                color.dim("[api]"),
861                color.blue(format!(
862                    "raw streamed response ({} bytes):",
863                    raw_events.len()
864                )),
865                preview(&raw_events, 20000)
866            );
867        }
868
869        if verbose {
870            let content = message_content_to_string(&message);
871            if !content.trim().is_empty() {
872                eprintln!(
873                    "{} {}\n{}",
874                    color.dim("[api]"),
875                    color.blue("assistant:"),
876                    preview(&content, DUMP_MAX_CHARS)
877                );
878            }
879        }
880        if let Some(u) = streamed_usage {
881            usage.add(&u);
882            if verbose {
883                eprintln!(
884                    "{} {}",
885                    color.dim("[api]"),
886                    color.dim(format!("usage: {}", u.footer_line()))
887                );
888            }
889        }
890
891        let tool_calls: Option<Vec<Value>> = message
892            .get("tool_calls")
893            .and_then(|tc| tc.as_array())
894            .filter(|a| !a.is_empty())
895            .cloned()
896            .map(normalize_tool_calls);
897
898        // Fallback: some models emit tool calls as <tool_call>{...}</tool_call>
899        // text in `content` instead of the structured field. When structured
900        // calls are absent, parse them from the text and rebuild the assistant
901        // message so the follow-up request and tool_call_id references stay valid.
902        let (tool_calls, assistant_message) = if tool_calls.is_none() && tool_loop.is_some() {
903            let (parsed, stripped) = parse_text_tool_calls(&message_content_to_string(&message));
904            if parsed.is_empty() {
905                (tool_calls, message)
906            } else {
907                if verbose {
908                    eprintln!(
909                        "{} {}",
910                        color.dim("[api]"),
911                        color.yellow(format!(
912                            "{} tool call(s) parsed from <tool_call> text",
913                            parsed.len()
914                        ))
915                    );
916                }
917                let assistant = json!({
918                    "role": "assistant",
919                    "content": if stripped.is_empty() { Value::Null } else { Value::String(stripped) },
920                    "tool_calls": sanitize_tool_calls_for_history(&parsed),
921                });
922                (Some(parsed), assistant)
923            }
924        } else {
925            let assistant = match &tool_calls {
926                Some(calls) => {
927                    let mut assistant = message;
928                    if let Some(obj) = assistant.as_object_mut() {
929                        obj.insert(
930                            "tool_calls".to_string(),
931                            Value::Array(sanitize_tool_calls_for_history(calls)),
932                        );
933                    }
934                    assistant
935                }
936                None => message,
937            };
938            (tool_calls, assistant)
939        };
940
941        if let (Some(arr), Some(cfg)) = (tool_calls, tool_loop) {
942            messages.push(assistant_message);
943
944            // Summary mode: tools are off; acknowledge any stray calls and let
945            // the model reply with its one-line summary.
946            if disable_tools {
947                for tc in &arr {
948                    if let Some(id) = tc.get("id").and_then(|x| x.as_str()) {
949                        messages.push(json!({
950                            "role": "tool",
951                            "tool_call_id": id,
952                            "content": "Tools are disabled now; reply with your one-line summary of the change you made."
953                        }));
954                    }
955                }
956                continue;
957            }
958
959            // Exploration budget exhausted. If nothing has been edited yet, force
960            // the model to make an edit_file call next; otherwise switch to
961            // summary mode. (Forcing is safe: a weak edit just gets reverted.)
962            if tool_iterations >= cfg.max_iterations && !force_edit {
963                let nudge = if cfg.allow_edit && edits_applied == 0 {
964                    force_edit = true;
965                    "Exploration budget reached. You MUST make at least one edit_file change now."
966                } else {
967                    disable_tools = true;
968                    "Exploration budget reached; reply with your one-line summary of the change you made."
969                };
970                for tc in &arr {
971                    if let Some(id) = tc.get("id").and_then(|x| x.as_str()) {
972                        messages
973                            .push(json!({"role": "tool", "tool_call_id": id, "content": nudge}));
974                    }
975                }
976                continue;
977            }
978            tool_iterations += 1;
979
980            for tc in &arr {
981                let id = tc
982                    .get("id")
983                    .and_then(|x| x.as_str())
984                    .ok_or_else(|| anyhow!("tool_calls[].id missing"))?;
985                let func = tc
986                    .get("function")
987                    .ok_or_else(|| anyhow!("tool_calls[].function missing"))?;
988                let name = func
989                    .get("name")
990                    .and_then(|x| x.as_str())
991                    .ok_or_else(|| anyhow!("tool_calls[].function.name missing"))?;
992                let args = func
993                    .get("arguments")
994                    .map(arguments_to_string)
995                    .unwrap_or_else(|| "{}".to_string());
996
997                // Always surface the tool call (not just in verbose mode), with
998                // the run's cumulative token usage so far appended.
999                eprintln!(
1000                    "{} {}  ({})",
1001                    color.dim("  ->"),
1002                    color.cyan(tool_call_label(name, &args)),
1003                    color.dim(usage.footer_line())
1004                );
1005
1006                // Short-circuit identical read-only calls. Re-running them returns
1007                // the same bytes and re-appends them to the conversation, which is
1008                // how a stuck model balloons the context; hand back a brief marker
1009                // instead so it moves on. Write tools are never deduped (a retry
1010                // with corrected args is legitimate, and they mutate state).
1011                if !tools::is_write_tool(name)
1012                    && !seen_tool_calls.insert(format!("{name}\u{0}{args}"))
1013                {
1014                    eprintln!(
1015                        "{} {} {}",
1016                        color.dim("  <-"),
1017                        color.cyan(tool_call_label(name, &args)),
1018                        color.yellow("skipped: identical call already made this turn")
1019                    );
1020                    messages.push(json!({
1021                        "role": "tool",
1022                        "tool_call_id": id,
1023                        "content": "Identical call already executed earlier in this turn; \
1024                                    its result has not changed and is shown above. Do not \
1025                                    repeat it - use that result, read a different location, \
1026                                    or make your edit_file change now.",
1027                    }));
1028                    continue;
1029                }
1030
1031                let tool_started = Instant::now();
1032                let out = match tokio::select! {
1033                    out = tools::execute_tool_async(
1034                    &cfg.sandbox,
1035                    cfg.scheds_root.as_deref(),
1036                    name,
1037                    &args,
1038                    cfg.allow_edit,
1039                    ) => out,
1040                    _ = interrupt::wait(interrupted.clone()) => return Err(interrupt::err()),
1041                    _ = tokio::time::sleep(deadline.remaining()) => {
1042                        return Err(deadline.timeout().into());
1043                    }
1044                } {
1045                    Ok(out) => {
1046                        let elapsed = tool_started.elapsed();
1047                        if verbose || elapsed >= SLOW_TOOL_LOG_AFTER {
1048                            eprintln!(
1049                                "{} {} done in {:.1}s ({} bytes)",
1050                                color.dim("  <-"),
1051                                color.cyan(tool_call_label(name, &args)),
1052                                elapsed.as_secs_f64(),
1053                                out.len()
1054                            );
1055                        }
1056                        if tools::is_write_tool(name) {
1057                            edits_applied += 1;
1058                        }
1059                        out
1060                    }
1061                    // Never abort on a tool error: hand it back so the model
1062                    // can retry. These are recoverable and often intermediate
1063                    // misses (e.g. edit_file old_string mismatches), so show
1064                    // them as warnings rather than errors in user-facing logs.
1065                    Err(e) => {
1066                        // Returned to the model verbatim; the ERROR: prefix
1067                        // helps it recognize the failed call and retry.
1068                        let msg = format!("ERROR: {e:#}");
1069                        // Displayed as a warning (no ERROR: prefix, yellow):
1070                        // the failure is recoverable, not a hard error.
1071                        let shown = format!("{e:#}");
1072                        eprintln!(
1073                            "{} {} failed after {:.1}s: {}",
1074                            color.dim("  <-"),
1075                            color.cyan(tool_call_label(name, &args)),
1076                            tool_started.elapsed().as_secs_f64(),
1077                            color.yellow(preview(shown.lines().next().unwrap_or(&shown), 300))
1078                        );
1079                        if verbose {
1080                            eprintln!(
1081                                "     {}",
1082                                color.yellow(preview(shown.lines().next().unwrap_or(&shown), 300))
1083                            );
1084                        }
1085                        msg
1086                    }
1087                };
1088                if verbose {
1089                    eprintln!(
1090                        "{} {}\n{}",
1091                        color.dim("[api]"),
1092                        color.blue(format!("tool result [{name}]:")),
1093                        preview(&out, 2000)
1094                    );
1095                }
1096                messages.push(json!({
1097                    "role": "tool",
1098                    "tool_call_id": id,
1099                    "content": out,
1100                }));
1101            }
1102            // Once a forced edit lands, stop forcing but RESUME normal tool use
1103            // (tool_choice=auto) so the model is free to make further edits if it
1104            // wants, then summarize. If it errored (edits still 0), keep
1105            // force_edit set to retry; the loop-top attempt counter bounds it.
1106            if force_edit && edits_applied > 0 {
1107                force_edit = false;
1108            }
1109            continue;
1110        }
1111
1112        // The model ended its turn with no tool call. If it never edited and we
1113        // can still force one, push a directive and force edit_file next (covers
1114        // the model "giving up" before hitting the exploration budget).
1115        if let Some(cfg) = tool_loop {
1116            if cfg.allow_edit && edits_applied == 0 && !disable_tools {
1117                force_edit = true;
1118                messages.push(assistant_message);
1119                messages.push(json!({
1120                    "role": "user",
1121                    "content": "You have not made any edit yet. Make your edit_file change(s) now - actually call the tool, do not just describe the change."
1122                }));
1123                continue;
1124            }
1125        }
1126
1127        let content = message_content_to_string(&assistant_message);
1128        return Ok(content);
1129    }
1130}
1131
1132/// Convenience: a tool loop config rooted at `sandbox`.
1133pub fn tool_loop(
1134    sandbox: &Path,
1135    scheds_root: Option<&Path>,
1136    allow_edit: bool,
1137    max_iterations: u32,
1138) -> ToolLoopConfig {
1139    ToolLoopConfig {
1140        sandbox: sandbox.to_path_buf(),
1141        scheds_root: scheds_root.map(Path::to_path_buf),
1142        allow_edit,
1143        max_iterations,
1144    }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149    use super::*;
1150
1151    #[test]
1152    fn system_message_cache_markers() {
1153        let cached = system_message("SYS", true);
1154        assert_eq!(cached["content"][0]["text"], "SYS");
1155        assert_eq!(cached["content"][0]["cache_control"]["type"], "ephemeral");
1156
1157        let plain = system_message("SYS", false);
1158        assert_eq!(plain["content"], "SYS");
1159        assert!(plain["content"].is_string());
1160    }
1161
1162    #[test]
1163    fn api_status_errors_are_classified() {
1164        let err: anyhow::Error = ApiStatusError {
1165            status: reqwest::StatusCode::BAD_REQUEST,
1166            body: "Already borrowed".to_string(),
1167        }
1168        .into();
1169
1170        assert!(is_api_error(&err));
1171        assert!(error_summary(&err).contains("Already borrowed"));
1172    }
1173
1174    #[test]
1175    fn tool_call_label_includes_scheduler_for_scheduler_file_reads() {
1176        let args = json!({
1177            "scheduler": "scx_rusty",
1178            "path": "src/bpf/main.bpf.c",
1179            "start_line": 10,
1180            "end_line": 40,
1181        })
1182        .to_string();
1183        assert_eq!(
1184            tool_call_label("read_scheduler_file", &args),
1185            "read_scheduler_file(scx_rusty/src/bpf/main.bpf.c:10-40)"
1186        );
1187    }
1188
1189    #[test]
1190    fn tool_call_label_includes_range_for_file_reads() {
1191        let bounded = json!({
1192            "path": "src/main.rs",
1193            "start_line": 20,
1194            "end_line": 80,
1195        })
1196        .to_string();
1197        assert_eq!(
1198            tool_call_label("read_file", &bounded),
1199            "read_file(src/main.rs:20-80)"
1200        );
1201
1202        let unbounded = json!({
1203            "path": "src/main.rs",
1204        })
1205        .to_string();
1206        assert_eq!(
1207            tool_call_label("read_file", &unbounded),
1208            "read_file(src/main.rs:1-EOF)"
1209        );
1210    }
1211
1212    #[test]
1213    fn tool_call_label_includes_scheduler_for_scheduler_grep() {
1214        let args = json!({
1215            "scheduler": "scx_lavd",
1216            "pattern": "vtime",
1217        })
1218        .to_string();
1219        assert_eq!(
1220            tool_call_label("grep_schedulers", &args),
1221            "grep_schedulers(scx_lavd:vtime)"
1222        );
1223    }
1224
1225    #[test]
1226    fn parses_single_text_tool_call() {
1227        let content = "I'll lower the slice.\n<tool_call>\n{\"name\": \"edit_file\", \"arguments\": {\"path\": \"src/bpf/main.bpf.c\", \"old_string\": \"a\", \"new_string\": \"b\"}}\n</tool_call>";
1228        let (calls, stripped) = parse_text_tool_calls(content);
1229        assert_eq!(calls.len(), 1);
1230        assert_eq!(calls[0]["function"]["name"], "edit_file");
1231        let args: Value =
1232            serde_json::from_str(calls[0]["function"]["arguments"].as_str().unwrap()).unwrap();
1233        assert_eq!(args["path"], "src/bpf/main.bpf.c");
1234        assert_eq!(calls[0]["type"], "function");
1235        assert!(calls[0]["id"].as_str().unwrap().starts_with("text_call_"));
1236        assert_eq!(stripped, "I'll lower the slice.");
1237    }
1238
1239    #[test]
1240    fn parses_multiple_calls_and_unique_ids() {
1241        let content = "<tool_call>{\"name\":\"grep\",\"arguments\":{\"pattern\":\"x\"}}</tool_call><tool_call>{\"name\":\"read_file\",\"arguments\":{\"path\":\"y\"}}</tool_call>";
1242        let (calls, stripped) = parse_text_tool_calls(content);
1243        assert_eq!(calls.len(), 2);
1244        assert_eq!(calls[0]["function"]["name"], "grep");
1245        assert_eq!(calls[1]["function"]["name"], "read_file");
1246        assert!(calls[0]["function"]["arguments"].is_string());
1247        assert!(calls[1]["function"]["arguments"].is_string());
1248        assert_ne!(calls[0]["id"], calls[1]["id"]);
1249        assert_eq!(stripped, "");
1250    }
1251
1252    #[test]
1253    fn accepts_parameters_key_and_missing_close_tag() {
1254        // "parameters" instead of "arguments", and no closing tag.
1255        let content = "<tool_call>{\"name\":\"list_dir\",\"parameters\":{\"path\":\".\"}}";
1256        let (calls, _) = parse_text_tool_calls(content);
1257        assert_eq!(calls.len(), 1);
1258        assert_eq!(calls[0]["function"]["name"], "list_dir");
1259        let args: Value =
1260            serde_json::from_str(calls[0]["function"]["arguments"].as_str().unwrap()).unwrap();
1261        assert_eq!(args["path"], ".");
1262    }
1263
1264    #[test]
1265    fn normalizes_structured_tool_call_arguments_for_replay() {
1266        let calls = normalize_tool_calls(vec![json!({
1267            "id": "call_0",
1268            "type": "function",
1269            "function": {
1270                "name": "list_dir",
1271                "arguments": {"path": "."},
1272            },
1273        })]);
1274
1275        assert_eq!(calls.len(), 1);
1276        assert!(calls[0]["function"]["arguments"].is_string());
1277        let args: Value =
1278            serde_json::from_str(calls[0]["function"]["arguments"].as_str().unwrap()).unwrap();
1279        assert_eq!(args["path"], ".");
1280    }
1281
1282    #[test]
1283    fn normalizes_missing_tool_call_arguments_to_empty_object_string() {
1284        let calls = normalize_tool_calls(vec![json!({
1285            "id": "call_0",
1286            "type": "function",
1287            "function": {"name": "list_schedulers"},
1288        })]);
1289
1290        assert_eq!(calls[0]["function"]["arguments"], "{}");
1291    }
1292
1293    #[test]
1294    fn sanitize_replaces_malformed_tool_call_arguments_but_keeps_valid_ones() {
1295        // A truncated/unterminated arguments string (as emitted when the model's
1296        // output is cut off) must be replaced with `{}` so the re-sent history is
1297        // valid JSON and the next request does not 400; valid calls are untouched.
1298        let calls = vec![
1299            json!({
1300                "id": "bad",
1301                "type": "function",
1302                "function": {
1303                    "name": "edit_file",
1304                    "arguments": "{\"path\": \"src/main.rs\", \"old_string\": \"void f(",
1305                },
1306            }),
1307            json!({
1308                "id": "good",
1309                "type": "function",
1310                "function": {
1311                    "name": "read_file",
1312                    "arguments": "{\"path\": \"src/main.rs\"}",
1313                },
1314            }),
1315        ];
1316
1317        let sanitized = sanitize_tool_calls_for_history(&calls);
1318        assert_eq!(sanitized[0]["function"]["arguments"], "{}");
1319        // The original (malformed) call is left intact for execution.
1320        assert_ne!(calls[0]["function"]["arguments"], "{}");
1321        // A well-formed call is passed through verbatim.
1322        assert_eq!(
1323            sanitized[1]["function"]["arguments"],
1324            "{\"path\": \"src/main.rs\"}"
1325        );
1326    }
1327
1328    #[test]
1329    fn assembles_streamed_tool_call_deltas() {
1330        let mut streamed = StreamedChat::default();
1331
1332        let visible = apply_stream_delta(
1333            &json!({
1334                "content": "I will inspect it.",
1335                "tool_calls": [{
1336                    "index": 0,
1337                    "id": "call_1",
1338                    "type": "function",
1339                    "function": {"name": "read_", "arguments": "{\"path\":\"src"}
1340                }]
1341            }),
1342            &mut streamed,
1343        )
1344        .unwrap();
1345        assert_eq!(visible.as_deref(), Some("I will inspect it."));
1346
1347        apply_stream_delta(
1348            &json!({
1349                "tool_calls": [{
1350                    "index": 0,
1351                    "function": {"name": "file", "arguments": "/bpf/main.bpf.c\"}"}
1352                }]
1353            }),
1354            &mut streamed,
1355        )
1356        .unwrap();
1357
1358        let message = streamed.into_message();
1359        assert_eq!(message["content"], "I will inspect it.");
1360        assert_eq!(message["tool_calls"][0]["id"], "call_1");
1361        assert_eq!(message["tool_calls"][0]["function"]["name"], "read_file");
1362        let args: Value = serde_json::from_str(
1363            message["tool_calls"][0]["function"]["arguments"]
1364                .as_str()
1365                .unwrap(),
1366        )
1367        .unwrap();
1368        assert_eq!(args["path"], "src/bpf/main.bpf.c");
1369    }
1370
1371    #[test]
1372    fn stream_printer_handles_multibyte_visible_prefix() {
1373        let mut printer = StreamTextPrinter::new(true);
1374        printer.push("或许是 **").unwrap();
1375        printer.finish().unwrap();
1376    }
1377
1378    #[test]
1379    fn stream_printer_handles_multibyte_hidden_tool_call_buffer() {
1380        let mut printer = StreamTextPrinter::new(true);
1381        printer.push("<tool_call>或许是 **").unwrap();
1382        printer.finish().unwrap();
1383    }
1384
1385    #[test]
1386    fn stream_printer_suppresses_whitespace_before_text_tool_call() {
1387        let mut printer = StreamTextPrinter::new(true);
1388        printer
1389            .push("\n\n<tool_call>{\"name\":\"list_dir\"}")
1390            .unwrap();
1391        printer.finish().unwrap();
1392
1393        assert!(!printer.started);
1394    }
1395
1396    #[test]
1397    fn stream_printer_suppresses_whitespace_only_structured_tool_call_text() {
1398        let mut printer = StreamTextPrinter::new(true);
1399        printer.push("\n\n").unwrap();
1400        printer.note_tool_call();
1401        printer.finish().unwrap();
1402
1403        assert!(!printer.started);
1404    }
1405
1406    #[test]
1407    fn stream_printer_trims_blank_lines_before_tool_call_after_text() {
1408        let mut printer = StreamTextPrinter::new(true);
1409        printer
1410            .push("Let me inspect:\n\n<tool_call>{\"name\":\"list_dir\"}")
1411            .unwrap();
1412        printer.finish().unwrap();
1413
1414        assert!(printer.started);
1415        assert!(!printer.last_was_newline);
1416    }
1417
1418    #[test]
1419    fn floor_char_boundary_does_not_split_utf8() {
1420        let text = "或许是 **";
1421        assert_eq!(floor_char_boundary(text, 0), 0);
1422        assert_eq!(floor_char_boundary(text, 1), 0);
1423        assert_eq!(floor_char_boundary(text, 2), 0);
1424        assert_eq!(floor_char_boundary(text, 3), 3);
1425    }
1426
1427    #[test]
1428    fn parses_sse_data_events() {
1429        let event = b"event: ignored\ndata: {\"a\":1}\n\n";
1430        assert_eq!(sse_data(event).unwrap().as_deref(), Some("{\"a\":1}"));
1431        let (pos, len) = find_sse_delimiter(event).unwrap();
1432        assert_eq!(&event[pos..pos + len], b"\n\n");
1433    }
1434
1435    #[test]
1436    fn no_tool_call_text_yields_no_calls() {
1437        let (calls, stripped) = parse_text_tool_calls("just a plain summary line");
1438        assert!(calls.is_empty());
1439        assert_eq!(stripped, "just a plain summary line");
1440    }
1441
1442    #[test]
1443    fn skips_malformed_block() {
1444        let (calls, _) = parse_text_tool_calls("<tool_call>not json</tool_call>");
1445        assert!(calls.is_empty());
1446    }
1447}