1 //! Inference backend abstraction.
2 //!
3 //! The agent loop only needs to send a turn (optionally with tools) and return
4 //! tool results. This module defines that seam as the `InferenceBackend` trait
5 //! plus a few neutral types, with two implementations:
6 //!
7 //! - `LocalBackend` runs on-device through the `onde` crate (`ChatEngine`).
8 //! - `OpenAiBackend` talks to any OpenAI-compatible HTTP endpoint, configured by
9 //! `base_url`, `api_key`, and `model`.
10 //!
11 //! The trait exposes neither `onde` nor OpenAI types, so the loop does not depend
12 //! on a specific backend.
13 //!
14 //! The seam is consumed by both surfaces: the interactive client (`#[cfg(unix)]`,
15 //! see `run_interactive` in `main.rs` and `mod tui` in `chat.rs`) and the ACP
16 //! server's prompt loop. Some items are still reached only through the
17 //! Unix-only interactive paths, so the dead-code lint stays suppressed on
18 //! non-Unix targets only — Unix builds keep full coverage.
19 #![cfg_attr(not(unix), allow(dead_code))]
20
21 use std::sync::Arc;
22
23 use async_trait::async_trait;
24 use onde::inference::{ChatEngine, ChatMessage, ChatRole, EngineStatus, ToolDefinition};
25 use serde::Deserialize;
26 use tokio::sync::Mutex;
27
28 // ── Neutral types ───────────────────────────────────────────────────────────────
29
30 /// A tool the model may call, in a provider-neutral form. `parameters_schema` is
31 /// a JSON Schema encoded as a string (matching how siGit Code already declares
32 /// tools).
33 #[derive(Debug, Clone)]
34 pub struct ToolSpec {
35 pub name: String,
36 pub description: String,
37 pub parameters_schema: String,
38 }
39
40 /// A tool call requested by the model.
41 #[derive(Debug, Clone)]
42 pub struct ToolCall {
43 pub id: String,
44 pub name: String,
45 /// Arguments as a JSON-encoded string.
46 pub arguments: String,
47 }
48
49 /// The output of executing one tool call, fed back to the model.
50 #[derive(Debug, Clone)]
51 pub struct ToolResult {
52 pub tool_call_id: String,
53 pub content: String,
54 }
55
56 /// The result of one assistant turn: free text and/or tool calls.
57 #[derive(Debug, Clone, Default)]
58 pub struct TurnResult {
59 pub text: String,
60 pub tool_calls: Vec<ToolCall>,
61 /// Why the model stopped. Only the last round of a turn decides how the
62 /// turn is reported; a round that goes on to run tools is not the end.
63 pub finish: FinishReason,
64 }
65
66 /// Why the model stopped generating, reduced to what the agent loop acts on.
67 /// Endpoints report it as `finish_reason`; without it a reply cut off at the
68 /// token limit is indistinguishable from one the model finished.
69 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70 pub enum FinishReason {
71 /// The model finished on its own: `stop`, `tool_calls`, or no reason given.
72 #[default]
73 Complete,
74 /// The output ran into the token limit, so the reply is cut short.
75 Length,
76 /// The endpoint withheld the reply instead of answering.
77 ContentFilter,
78 }
79
80 impl FinishReason {
81 /// Map a wire `finish_reason`. OpenAI's spellings are `length` and
82 /// `content_filter`; gateways in front of other providers sometimes pass
83 /// the upstream's own word through, so those are accepted too.
84 fn from_wire(reason: Option<&str>) -> Self {
85 match reason {
86 Some("length" | "max_tokens") => Self::Length,
87 Some("content_filter" | "refusal") => Self::ContentFilter,
88 _ => Self::Complete,
89 }
90 }
91 }
92
93 /// Backend errors are plain strings. Callers map them to ACP errors.
94 pub type BackendError = String;
95
96 /// Rough context budget for a conversation, in estimated tokens (see
97 /// [`estimate_tokens`]). When a snapshot exceeds this, the agent loops compact
98 /// history before the next tool round.
99 pub const DEFAULT_CONTEXT_TOKEN_BUDGET: usize = 24_000;
100
101 /// How many trailing messages survive a compaction verbatim (the rest are
102 /// folded into the summary).
103 pub const COMPACT_KEEP_LAST: usize = 6;
104
105 /// The summarization request sent to the model when compacting history.
106 const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \
107 files touched, current state, open items. Be concise and factual.";
108
109 /// Largest transcript (in estimated tokens, see [`estimate_tokens`]) sent to
110 /// the model for summarization in one go.
111 ///
112 /// Compaction runs because the conversation has outgrown the context budget,
113 /// so shipping the whole transcript verbatim asks the endpoint to process
114 /// exactly the payload that is already too big — on siGit Code Cloud that
115 /// request sits in inference until the gateway gives up (504), which is the
116 /// failure behind issue #125. Capping the transcript keeps the summarization
117 /// round inside the window the model actually has; the oldest and newest
118 /// exchanges carry the intent and the current state, while the truncated
119 /// middle is the part a summary can afford to lose.
120 const SUMMARY_TRANSCRIPT_TOKEN_CAP: usize = 12_000;
121
122 /// Trim `transcript` to about `cap` estimated tokens by cutting whole lines
123 /// from the middle, keeping the opening and closing exchanges.
124 ///
125 /// A middle cut preserves the two ends that matter most to a summary: how the
126 /// session started (the original request) and where it stands now. Cutting by
127 /// whole lines keeps individual messages intact. A transcript already under
128 /// the cap is returned unchanged.
129 ///
130 /// Line boundaries are best-effort. A head with no newline in its half is
131 /// dropped and the tail keeps its own half. A tail with no newline in its half
132 /// (a single huge message, or a giant final tool result) can't be cut on a
133 /// line, so the newest `cap` worth of text is kept instead and the head goes:
134 /// the newest exchanges are the ones the summary can least afford to lose.
135 fn truncate_transcript_middle(transcript: &str, cap: usize) -> String {
136 let budget = cap * 4; // estimate_tokens is chars / 4; invert it.
137 if transcript.len() <= budget {
138 return transcript.to_string();
139 }
140
141 let total = transcript.len();
142 // Each side gets half the budget, so the two together stay inside the cap.
143 // Walk each half to a line boundary. A head with no boundary in reach
144 // contributes nothing; a tail with none takes the hard cut below.
145 let half = budget / 2;
146 let head_end = {
147 let mut cut = half.min(total);
148 while cut < total && !transcript.is_char_boundary(cut) {
149 cut += 1;
150 }
151 cut = cut.min(total);
152 if transcript[..cut].contains('\n') {
153 transcript[..cut].rfind('\n').unwrap() + 1
154 } else {
155 0
156 }
157 };
158 let tail_start = {
159 let mut cut = total.saturating_sub(half);
160 while cut < total && !transcript.is_char_boundary(cut) {
161 cut += 1;
162 }
163 if transcript[cut..].contains('\n') {
164 transcript[cut..]
165 .find('\n')
166 .map(|offset| cut + offset + 1)
167 .unwrap()
168 } else {
169 total
170 }
171 };
172
173 if tail_start == total {
174 // The closing exchange has no line boundary in reach (a transcript of
175 // one huge message, or a giant newline-less tool result at the end):
176 // keep the newest budget. A head-only cut would invert the contract
177 // while the placeholder claims older messages were dropped.
178 let mut start = total.saturating_sub(budget);
179 while start > 0 && !transcript.is_char_boundary(start) {
180 start -= 1;
181 }
182 return format!(
183 "{}\n[…older messages omitted…]",
184 transcript[start..].trim_start()
185 );
186 }
187
188 if head_end >= tail_start {
189 // No line boundary in reach anywhere: keep the newest budget.
190 let mut start = total.saturating_sub(budget);
191 while start > 0 && !transcript.is_char_boundary(start) {
192 start -= 1;
193 }
194 return format!(
195 "{}\n[…older messages omitted…]",
196 transcript[start..].trim_start()
197 );
198 }
199
200 let omitted_chars = transcript[head_end..tail_start].chars().count();
201 format!(
202 "{}\n[…{} characters of older messages omitted…]\n{}",
203 transcript[..head_end].trim_end(),
204 omitted_chars,
205 transcript[tail_start..].trim_start(),
206 )
207 }
208
209 /// Render a history snapshot as a plain-text transcript, with tool calls and
210 /// tool results spelled out as prose rather than left in their wire shapes.
211 ///
212 /// Compaction summarizes a conversation that is, by definition, thick with
213 /// `tool_calls` and `role: "tool"` messages — but the summarization round asks
214 /// for a plain answer and so sends no `tools` array. Forwarding the raw shapes
215 /// in that request produces tool blocks with no schema to validate against,
216 /// which strict endpoints reject outright: Anthropic answers 400, so every
217 /// compaction of a session that had ever run a tool failed, permanently, no
218 /// matter how small the history was. Flattening to text keeps everything the
219 /// summary actually needs and drops the shapes that only make sense alongside
220 /// a tool schema. It also sidesteps orphaned `tool_call_id`s and role-
221 /// alternation rules, neither of which a transcript can violate.
222 fn transcript_for_summary(history: &[serde_json::Value]) -> String {
223 let mut lines: Vec<String> = Vec::new();
224 for message in history {
225 let role = message["role"].as_str().unwrap_or("user");
226 // The system prompt is carried over verbatim, so it needn't be summarized.
227 if role == "system" {
228 continue;
229 }
230
231 let mut parts: Vec<String> = Vec::new();
232 let text = message_text(message);
233 if !text.trim().is_empty() {
234 parts.push(text);
235 }
236 // A summary cannot carry a picture, but it should say one was there.
237 match message_image_count(message) {
238 0 => {}
239 1 => parts.push("[attached an image]".to_string()),
240 count => parts.push(format!("[attached {count} images]")),
241 }
242 for call in message["tool_calls"].as_array().into_iter().flatten() {
243 parts.push(format!(
244 "called {}({})",
245 call["function"]["name"].as_str().unwrap_or("tool"),
246 call["function"]["arguments"].as_str().unwrap_or_default(),
247 ));
248 }
249 if parts.is_empty() {
250 continue;
251 }
252
253 let label = if role == "tool" { "tool result" } else { role };
254 lines.push(format!("{label}: {}", parts.join("\n")));
255 }
256 lines.join("\n\n")
257 }
258
259 /// Crude token estimate for a history snapshot: serialized characters / 4.
260 /// Deliberately model-agnostic — it only needs to be in the right ballpark to
261 /// decide when compaction is worth an extra inference round.
262 pub fn estimate_tokens(history: &[serde_json::Value]) -> usize {
263 let mut chars = 0;
264 let mut images = 0;
265 for message in history {
266 let image_count = message_image_count(message);
267 if image_count == 0 {
268 chars += message.to_string().chars().count();
269 } else {
270 // An image travels as base64, megabytes of it, but a model bills
271 // it by resolution. Counting the payload as text would put every
272 // session with one screenshot permanently over budget.
273 chars += message_text(message).chars().count();
274 images += image_count;
275 }
276 }
277 chars / 4 + images * IMAGE_TOKEN_ESTIMATE
278 }
279
280 /// What one attached image is assumed to cost. Providers land between a few
281 /// hundred and a couple of thousand tokens depending on resolution; like the
282 /// rest of [`estimate_tokens`] this only has to be in the right ballpark.
283 const IMAGE_TOKEN_ESTIMATE: usize = 1_000;
284
285 /// The text of a history message. `content` is a plain string for almost every
286 /// message; a user message that carries an image uses OpenAI's content-part
287 /// array instead, and its text parts are joined here.
288 pub fn message_text(message: &serde_json::Value) -> String {
289 match &message["content"] {
290 serde_json::Value::String(text) => text.clone(),
291 serde_json::Value::Array(parts) => parts
292 .iter()
293 .filter_map(|part| part["text"].as_str())
294 .collect::<Vec<_>>()
295 .join("\n"),
296 _ => String::new(),
297 }
298 }
299
300 /// How many images a history message carries as `image_url` content parts.
301 pub fn message_image_count(message: &serde_json::Value) -> usize {
302 message["content"]
303 .as_array()
304 .map(|parts| {
305 parts
306 .iter()
307 .filter(|part| part["type"] == "image_url")
308 .count()
309 })
310 .unwrap_or(0)
311 }
312
313 /// A sink for streaming assistant text deltas to the UI as they are produced.
314 ///
315 /// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
316 /// text fragment through it as the model emits it; the returned [`TurnResult`]
317 /// still carries the fully assembled text (and any tool calls). When the sink is
318 /// `None`, the backend runs in non-streaming mode. Unbounded so the inference
319 /// task never blocks on a slow consumer.
320 pub type TokenSink = tokio::sync::mpsc::UnboundedSender<String>;
321
322 /// An image attached to a user message: base64 data and its media type, as an
323 /// ACP client sends it.
324 #[derive(Debug, Clone, PartialEq, Eq)]
325 pub struct ImageInput {
326 pub mime_type: String,
327 pub data: String,
328 }
329
330 /// What a model that cannot read images is shown in place of one.
331 const IMAGE_OMITTED_NOTE: &str = "[image omitted: this model cannot read images]";
332
333 /// A user message in history form. Plain text keeps the string `content` every
334 /// other message uses; with images it becomes OpenAI's content-part array,
335 /// text first, each image as a base64 `data:` URL.
336 fn user_message(text: &str, images: &[ImageInput]) -> serde_json::Value {
337 if images.is_empty() {
338 return serde_json::json!({ "role": "user", "content": text });
339 }
340 let mut parts = Vec::with_capacity(images.len() + 1);
341 if !text.is_empty() {
342 parts.push(serde_json::json!({ "type": "text", "text": text }));
343 }
344 for image in images {
345 parts.push(serde_json::json!({
346 "type": "image_url",
347 "image_url": {
348 "url": format!("data:{};base64,{}", image.mime_type, image.data),
349 },
350 }));
351 }
352 serde_json::json!({ "role": "user", "content": parts })
353 }
354
355 /// `history` as a text-only model has to receive it: a message that carries
356 /// images is flattened back to a string, with a note where each image was.
357 ///
358 /// History itself keeps the images. A thread can move to a model that reads
359 /// them (or back to one), so what was attached is not thrown away just because
360 /// the model active right now cannot use it.
361 fn without_images(history: &[serde_json::Value]) -> Vec<serde_json::Value> {
362 history
363 .iter()
364 .map(|message| {
365 let images = message_image_count(message);
366 if images == 0 {
367 return message.clone();
368 }
369 let mut text = message_text(message);
370 for _ in 0..images {
371 if !text.is_empty() {
372 text.push('\n');
373 }
374 text.push_str(IMAGE_OMITTED_NOTE);
375 }
376 let mut flattened = message.clone();
377 flattened["content"] = serde_json::Value::String(text);
378 flattened
379 })
380 .collect()
381 }
382
383 // ── The trait ───────────────────────────────────────────────────────────────────
384
385 /// A swappable inference backend driving siGit Code's agent loop.
386 #[async_trait]
387 pub trait InferenceBackend: Send + Sync {
388 /// Start an assistant turn from a new user message, offering `tools`.
389 ///
390 /// If `sink` is `Some`, text is streamed through it as it is generated. A
391 /// backend may decline to stream a given round (for example, on-device
392 /// inference cannot stream while it is still deciding whether to call a
393 /// tool); in that case the text is delivered only via the returned result.
394 async fn send_message_with_tools(
395 &self,
396 text: &str,
397 tools: &[ToolSpec],
398 sink: Option<&TokenSink>,
399 ) -> Result<TurnResult, BackendError>;
400
401 /// Continue the turn by returning tool results. `allow_tool_calls` controls
402 /// whether `tools` are offered again; the complete catalog remains available
403 /// so a disabled round can recognize and suppress tool-shaped model output.
404 /// `sink` streams assistant text when set.
405 async fn send_tool_results(
406 &self,
407 results: Vec<ToolResult>,
408 tools: &[ToolSpec],
409 allow_tool_calls: bool,
410 sink: Option<&TokenSink>,
411 ) -> Result<TurnResult, BackendError>;
412
413 /// Record tool results in the conversation history *without* asking the
414 /// model to continue the turn. Used when a turn is abandoned mid-round
415 /// (the user cancelled at the permission gate): by then the assistant
416 /// message carrying the tool calls is already in history, and leaving them
417 /// unanswered makes strict OpenAI-compatible endpoints reject every later
418 /// request in the session.
419 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
420
421 /// Start a turn from a user message that carries images.
422 ///
423 /// The default drops the images and sends the text, which is right for a
424 /// backend that cannot read them. Callers check [`Self::accepts_images`]
425 /// first so the user can be told, instead of the image vanishing.
426 async fn send_message_with_images(
427 &self,
428 text: &str,
429 images: &[ImageInput],
430 tools: &[ToolSpec],
431 sink: Option<&TokenSink>,
432 ) -> Result<TurnResult, BackendError> {
433 let _ = images;
434 self.send_message_with_tools(text, tools, sink).await
435 }
436
437 /// Whether the model behind this backend reads images. On-device models do
438 /// not; a remote one answers from its model id.
439 fn accepts_images(&self) -> bool {
440 false
441 }
442
443 /// Whether inference runs over the network (a configured provider) rather
444 /// than on-device. Drives UI labelling so the displayed model can't claim a
445 /// local model while requests actually go to the cloud.
446 fn is_remote(&self) -> bool;
447
448 /// A serializable snapshot of the conversation history, one JSON object per
449 /// message (`{"role": ..., "content": ...}` at minimum). The snapshot is
450 /// what the session store persists; it includes any seeded system message
451 /// so [`InferenceBackend::restore_history`] can replace state wholesale.
452 async fn history_snapshot(&self) -> Vec<serde_json::Value>;
453
454 /// Replace the conversation history with a previously saved snapshot.
455 /// Backends that cannot represent every entry (e.g. on-device history has
456 /// no tool-call structure) flatten what they can and drop the rest.
457 async fn restore_history(&self, history: Vec<serde_json::Value>);
458
459 /// Shrink the conversation history: summarize everything so far with one
460 /// extra (non-streaming) inference round, then rebuild history as
461 /// `[system message, summary, last keep_last non-system messages]`. When
462 /// the summarization round itself fails, a backend should fall back to a
463 /// deterministic shrink (dropping the oldest messages) rather than leave
464 /// an over-budget session stuck; an `Err` here means even that fallback
465 /// could not fit the history in the budget.
466 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError>;
467 }
468
469 // ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
470
471 /// On-device inference. A thin adapter over `onde::ChatEngine`.
472 pub struct LocalBackend {
473 engine: Arc<ChatEngine>,
474 /// History restored before an on-device model is loaded. Onde ignores
475 /// `push_history` while unloaded, so keep the snapshot here until the
476 /// engine can accept it instead of silently dropping a loaded session.
477 pending_history: Mutex<Option<Vec<serde_json::Value>>>,
478 }
479
480 impl LocalBackend {
481 pub fn new(engine: Arc<ChatEngine>) -> Self {
482 Self {
483 engine,
484 pending_history: Mutex::new(None),
485 }
486 }
487
488 async fn apply_pending_history(&self) -> Result<(), BackendError> {
489 if self.pending_history.lock().await.is_none() {
490 return Ok(());
491 }
492 let status = self.engine.info().await.status;
493 if status != EngineStatus::Ready {
494 return Err(format!(
495 "on-device model is not ready to restore session history (status: {status})"
496 ));
497 }
498 let Some(history) = self.pending_history.lock().await.take() else {
499 return Ok(());
500 };
501
502 self.engine.clear_history().await;
503 for entry in history {
504 let role = entry["role"].as_str().unwrap_or("");
505 // The on-device engine keeps text only, so an image attached
506 // while a cloud model was active stays behind here.
507 let content = message_text(&entry);
508 // Tool-call-only assistant entries and empty tool results carry no
509 // text a plain chat history can replay; drop them.
510 if content.is_empty() && role != "user" && role != "system" {
511 continue;
512 }
513 let message = match role {
514 "system" => ChatMessage::system(content),
515 "user" => ChatMessage::user(content),
516 "assistant" => ChatMessage::assistant(content),
517 // Tool results flatten to plain text (MVP; acceptable loss).
518 "tool" => ChatMessage::user(format!("[tool result]\n{content}")),
519 _ => continue,
520 };
521 self.engine.push_history(message).await;
522 }
523 Ok(())
524 }
525 }
526
527 fn to_onde_tools(tools: &[ToolSpec]) -> Vec<ToolDefinition> {
528 tools
529 .iter()
530 .map(|tool| ToolDefinition {
531 name: tool.name.clone(),
532 description: tool.description.clone(),
533 parameters_schema: tool.parameters_schema.clone(),
534 })
535 .collect()
536 }
537
538 #[async_trait]
539 impl InferenceBackend for LocalBackend {
540 async fn send_message_with_tools(
541 &self,
542 text: &str,
543 tools: &[ToolSpec],
544 sink: Option<&TokenSink>,
545 ) -> Result<TurnResult, BackendError> {
546 self.apply_pending_history().await?;
547 // onde's tool-aware path is non-streaming: it has to buffer the whole
548 // reply to detect tool calls. We can only stream when no tools are on
549 // offer (a plain answer), which is exactly the tools-disabled case.
550 if let Some(sink) = sink
551 && tools.is_empty()
552 {
553 let rx = self
554 .engine
555 .stream_message(text)
556 .await
557 .map_err(|error| error.to_string())?;
558 return drain_onde_stream(rx, sink).await;
559 }
560
561 let onde_tools = to_onde_tools(tools);
562 let result = self
563 .engine
564 .send_message_with_tools(text, &onde_tools)
565 .await
566 .map_err(|error| error.to_string())?;
567 Ok(onde_result_to_turn(result))
568 }
569
570 async fn send_tool_results(
571 &self,
572 results: Vec<ToolResult>,
573 tools: &[ToolSpec],
574 allow_tool_calls: bool,
575 sink: Option<&TokenSink>,
576 ) -> Result<TurnResult, BackendError> {
577 self.apply_pending_history().await?;
578 let onde_results: Vec<onde::inference::ToolResult> = results
579 .into_iter()
580 .map(|result| onde::inference::ToolResult {
581 tool_call_id: result.tool_call_id,
582 content: result.content,
583 })
584 .collect();
585
586 // A forced-text round is the only round onde can stream, since no
587 // further tool calls are parsed.
588 if let Some(sink) = sink
589 && !allow_tool_calls
590 {
591 let rx = self
592 .engine
593 .stream_tool_results(onde_results, None)
594 .await
595 .map_err(|error| error.to_string())?;
596 return drain_onde_stream(rx, sink).await;
597 }
598
599 let onde_tools = allow_tool_calls.then(|| to_onde_tools(tools));
600 let result = self
601 .engine
602 .send_tool_results(onde_results, onde_tools.as_deref())
603 .await
604 .map_err(|error| error.to_string())?;
605 Ok(onde_result_to_turn(result))
606 }
607
608 async fn record_cancelled_tool_results(&self, _results: Vec<ToolResult>) {
609 // onde's public API cannot append tool-result history entries without
610 // running another inference round, so the dangling tool call stays in
611 // its history. The chat template replays it as-is, which local models
612 // tolerate — worst case the model re-issues the call next turn.
613 }
614
615 fn is_remote(&self) -> bool {
616 false
617 }
618
619 async fn history_snapshot(&self) -> Vec<serde_json::Value> {
620 if let Some(history) = self.pending_history.lock().await.as_ref() {
621 return history.clone();
622 }
623 // onde's `history()` already flattens tool entries: assistant tool
624 // calls become plain assistant text and tool results are omitted, so
625 // the snapshot is lossy for tool-heavy turns (acceptable in this MVP).
626 self.engine
627 .history()
628 .await
629 .iter()
630 .map(|message| {
631 serde_json::json!({
632 "role": message.role.to_string(),
633 "content": message.content,
634 })
635 })
636 .collect()
637 }
638
639 async fn restore_history(&self, history: Vec<serde_json::Value>) {
640 *self.pending_history.lock().await = Some(history);
641 if self.engine.info().await.status == EngineStatus::Ready {
642 // Ready was just observed, so failure here can only mean a status
643 // transition; keep the pending copy for the next inference call.
644 let _ = self.apply_pending_history().await;
645 }
646 }
647
648 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
649 self.apply_pending_history().await?;
650 let snapshot = self.engine.history().await;
651
652 // Leading system messages carry session context; keep them all.
653 let system: Vec<ChatMessage> = snapshot
654 .iter()
655 .take_while(|message| message.role == ChatRole::System)
656 .cloned()
657 .collect();
658 let non_system: Vec<ChatMessage> = snapshot
659 .into_iter()
660 .filter(|message| message.role != ChatRole::System)
661 .collect();
662 let tail_start = non_system.len().saturating_sub(keep_last);
663 let tail: Vec<ChatMessage> = non_system[tail_start..].to_vec();
664
665 // One plain (tool-free) inference round produces the summary. Note the
666 // cap only bounds this request's own payload: the engine still replays
667 // its full (over-budget) history underneath it, so an already
668 // over-budget local session usually fails here and lands in the
669 // fallback below — the cap is about not making the doomed round worse.
670 let transcript = truncate_transcript_middle(
671 &chat_messages_transcript(&non_system),
672 SUMMARY_TRANSCRIPT_TOKEN_CAP,
673 );
674 let result = self
675 .engine
676 .send_message(format!("{transcript}\n\n{SUMMARIZE_PROMPT}"))
677 .await
678 .map_err(|error| error.to_string());
679
680 let rebuild: Vec<ChatMessage> = match result {
681 Ok(result) => {
682 // Local models may reason in <think> blocks; keep only the visible part.
683 let (_think, summary) = crate::chat::strip_think_blocks(&result.text);
684 let mut rebuilt = system;
685 rebuilt.push(ChatMessage::user(format!(
686 "[Conversation summary]\n{summary}"
687 )));
688 rebuilt.extend(tail);
689 rebuilt
690 }
691 Err(error) => {
692 log::warn!(
693 "local summarization round failed ({error}); falling back to plain truncation"
694 );
695 // Same deterministic shrink as the remote path, on the plain
696 // chat shapes the engine holds (its history has no tool-call
697 // structure, so there are no orphaned tool results to guard).
698 match truncate_chat_messages_to_budget(system, tail) {
699 Some(rebuilt) => rebuilt,
700 // The system messages alone overflow the window: unrecoverable.
701 None => return Err(error),
702 }
703 }
704 };
705
706 self.engine.clear_history().await;
707 for message in rebuild {
708 self.engine.push_history(message).await;
709 }
710 Ok(())
711 }
712 }
713
714 /// The local counterpart of `truncate_history_to_budget`: system messages, the
715 /// truncation placeholder, and the newest of `tail` that fit the budget.
716 ///
717 /// The tail may drain to empty. A newest message that overflows the window on
718 /// its own (a giant tool result, the very thing that triggers compaction) is
719 /// dropped rather than failing the session, so `None` only means the system
720 /// messages alone don't fit.
721 fn truncate_chat_messages_to_budget(
722 system: Vec<ChatMessage>,
723 mut tail: Vec<ChatMessage>,
724 ) -> Option<Vec<ChatMessage>> {
725 let budget = DEFAULT_CONTEXT_TOKEN_BUDGET * 4; // estimate_tokens is chars / 4.
726 let fixed_chars: usize = system
727 .iter()
728 .map(|message| message.content.len())
729 .sum::<usize>()
730 + TRUNCATION_PLACEHOLDER.len();
731 if fixed_chars > budget {
732 return None;
733 }
734
735 let mut tail_chars: usize = tail.iter().map(|message| message.content.len()).sum();
736 let mut drop = 0;
737 while fixed_chars + tail_chars > budget {
738 tail_chars -= tail[drop].content.len();
739 drop += 1;
740 }
741 tail.drain(..drop);
742
743 let mut rebuilt = system;
744 rebuilt.push(ChatMessage::user(TRUNCATION_PLACEHOLDER.to_string()));
745 rebuilt.extend(tail);
746 Some(rebuilt)
747 }
748
749 /// Flatten `onde` chat messages into the transcript shape summarization
750 /// expects (the local engine's history has no tool-call structure, so this is
751 /// a plain role/content rendering).
752 fn chat_messages_transcript(history: &[ChatMessage]) -> String {
753 history
754 .iter()
755 .filter(|message| message.role != ChatRole::System && !message.content.trim().is_empty())
756 .map(|message| format!("{}: {}", message.role, message.content))
757 .collect::<Vec<_>>()
758 .join("\n\n")
759 }
760
761 /// Drain an onde streaming receiver, forwarding each token to `sink` and
762 /// assembling the full text. onde reports stream failures as a final chunk whose
763 /// `finish_reason` is `"error: …"`; surface those as a backend error.
764 async fn drain_onde_stream(
765 mut rx: tokio::sync::mpsc::Receiver<onde::inference::StreamChunk>,
766 sink: &TokenSink,
767 ) -> Result<TurnResult, BackendError> {
768 let mut text = String::new();
769 let mut finish = FinishReason::default();
770 while let Some(chunk) = rx.recv().await {
771 if !chunk.delta.is_empty() {
772 text.push_str(&chunk.delta);
773 // The receiver is the UI; if it's gone the turn is being cancelled,
774 // so stop assembling rather than spinning the model to completion.
775 if sink.send(chunk.delta).is_err() {
776 break;
777 }
778 }
779 if chunk.done {
780 if let Some(reason) = chunk.finish_reason.as_deref()
781 && let Some(message) = reason.strip_prefix("error: ")
782 {
783 return Err(message.to_string());
784 }
785 finish = FinishReason::from_wire(chunk.finish_reason.as_deref());
786 break;
787 }
788 }
789 Ok(TurnResult {
790 text,
791 tool_calls: Vec::new(),
792 finish,
793 })
794 }
795
796 /// Convert an `onde` tool-aware result into the neutral [`TurnResult`].
797 fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
798 TurnResult {
799 finish: FinishReason::from_wire(Some(&result.finish_reason)),
800 text: result.text,
801 tool_calls: result
802 .tool_calls
803 .into_iter()
804 .map(|call| ToolCall {
805 id: call.id,
806 name: call.function_name,
807 arguments: call.arguments,
808 })
809 .collect(),
810 }
811 }
812
813 // ── OpenAI-compatible backend ─────────────────────────────────────────────────────
814
815 /// Inference against any OpenAI-compatible Chat Completions endpoint.
816 ///
817 /// Conversation state is held client-side and replayed on every request, so the
818 /// endpoint can be stateless. Standard OpenAI function-calling is used end to
819 /// end (`tools`, `choices[].message.tool_calls`, `role: "tool"` follow-ups).
820 pub struct OpenAiBackend {
821 base_url: String,
822 api_key: String,
823 model: String,
824 http: reqwest::Client,
825 /// The full message list sent on each request (system + turns + tool results).
826 history: Mutex<Vec<serde_json::Value>>,
827 /// Whether `model` reads images (see `provider::model_accepts_images`).
828 /// When it does not, images already in `history` are left out of each
829 /// request rather than sent to an endpoint that would refuse them.
830 accepts_images: bool,
831 }
832
833 impl OpenAiBackend {
834 /// Build a backend for `{base_url, api_key, model}`, seeding the optional
835 /// system prompt. `base_url` should include the API root (e.g. ending in
836 /// `/v1`); the chat path is appended.
837 pub fn new(
838 base_url: impl Into<String>,
839 api_key: impl Into<String>,
840 model: impl Into<String>,
841 system_prompt: Option<String>,
842 ) -> Self {
843 let mut history = Vec::new();
844 if let Some(prompt) = system_prompt {
845 history.push(serde_json::json!({ "role": "system", "content": prompt }));
846 }
847 let model = model.into();
848 Self {
849 base_url: base_url.into(),
850 api_key: api_key.into(),
851 accepts_images: crate::provider::model_accepts_images(&model),
852 model,
853 http: reqwest::Client::new(),
854 history: Mutex::new(history),
855 }
856 }
857
858 fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
859 tools
860 .iter()
861 .map(|tool| {
862 // parameters_schema is a JSON string; parse it, defaulting to an
863 // empty object schema if malformed.
864 let parameters: serde_json::Value = serde_json::from_str(&tool.parameters_schema)
865 .unwrap_or_else(|_| serde_json::json!({ "type": "object", "properties": {} }));
866 serde_json::json!({
867 "type": "function",
868 "function": {
869 "name": tool.name,
870 "description": tool.description,
871 "parameters": parameters,
872 }
873 })
874 })
875 .collect()
876 }
877
878 /// POST the current history and apply the assistant reply to history.
879 /// `tools` is always the known catalog, while `allow_tool_calls` determines
880 /// whether it is advertised to the model and whether returned calls may run.
881 /// Streams via SSE when `sink` is set; otherwise reads a single JSON response.
882 ///
883 /// A reply whose only attempt at a tool call was a block that couldn't be
884 /// parsed gets one retry: the model is told the call didn't run and asked
885 /// to make it again through the structured interface. Without that the
886 /// turn ends on whatever prose preceded the broken block, and the call is
887 /// silently lost.
888 async fn complete(
889 &self,
890 tools: &[ToolSpec],
891 allow_tool_calls: bool,
892 sink: Option<&TokenSink>,
893 ) -> Result<TurnResult, BackendError> {
894 let (result, malformed) = self.request(tools, allow_tool_calls, sink).await?;
895 if malformed == 0 || !allow_tool_calls || !result.tool_calls.is_empty() {
896 return Ok(result);
897 }
898
899 log::warn!(
900 "dropped {malformed} unparseable inline tool call(s); asking the model to retry"
901 );
902 self.history.lock().await.push(serde_json::json!({
903 "role": "user",
904 "content": MALFORMED_TOOL_CALL_RETRY,
905 }));
906 if let Some(sink) = sink
907 && !result.text.trim().is_empty()
908 {
909 // The retry's text follows what already streamed; keep it from
910 // running on into the previous sentence.
911 let _ = sink.send("\n\n".to_string());
912 }
913 let (retry, retry_malformed) = self.request(tools, allow_tool_calls, sink).await?;
914 if retry_malformed > 0 && retry.tool_calls.is_empty() {
915 log::warn!(
916 "the retry also wrote {retry_malformed} unparseable inline tool call(s); \
917 ending the turn without them"
918 );
919 }
920 Ok(TurnResult {
921 text: join_reply_text(&result.text, &retry.text),
922 tool_calls: retry.tool_calls,
923 finish: retry.finish,
924 })
925 }
926
927 /// One chat-completion round trip. Returns the turn plus how many inline
928 /// tool-call blocks were dropped because they couldn't be parsed.
929 async fn request(
930 &self,
931 tools: &[ToolSpec],
932 allow_tool_calls: bool,
933 sink: Option<&TokenSink>,
934 ) -> Result<(TurnResult, usize), BackendError> {
935 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
936 let streaming = sink.is_some();
937
938 let messages = {
939 let history = self.history.lock().await;
940 if self.accepts_images {
941 history.clone()
942 } else {
943 without_images(&history)
944 }
945 };
946 let mut body = serde_json::json!({
947 "model": self.model,
948 "messages": messages,
949 "stream": streaming,
950 });
951 if allow_tool_calls && !tools.is_empty() {
952 body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
953 // OpenAI specifies `auto` as the default when tools are present,
954 // but not every OpenAI-compatible gateway implements that default.
955 // Sending it explicitly is important for the cloud path: otherwise
956 // a model can describe the action it intends to take as text and
957 // end the turn without returning a tool call at all.
958 body["tool_choice"] = serde_json::Value::String("auto".to_string());
959 }
960
961 let response = self
962 .http
963 .post(&url)
964 .bearer_auth(&self.api_key)
965 .json(&body)
966 .send()
967 .await
968 .map_err(|error| format!("request to {url} failed: {error}"))?;
969
970 if !response.status().is_success() {
971 let status = response.status();
972 let body = response.text().await.unwrap_or_default();
973 return Err(describe_api_error(status, &body));
974 }
975
976 if let Some(sink) = sink {
977 self.consume_stream(response, sink, tools, allow_tool_calls)
978 .await
979 } else {
980 self.consume_json(response, tools, allow_tool_calls).await
981 }
982 }
983
984 /// One self-contained summarization round: summarize `transcript` without
985 /// touching the live history.
986 ///
987 /// Unlike [`complete`], which replays and mutates `self.history`, this
988 /// sends its own one-off message list. Compaction only rewrites history
989 /// once it has a summary in hand, so a failed round leaves the session
990 /// exactly as it was — and the caller can fall back to plain truncation
991 /// instead of failing the session (issue #125).
992 async fn summarize(
993 &self,
994 system: &[serde_json::Value],
995 transcript: &str,
996 ) -> Result<String, BackendError> {
997 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
998 let mut messages = Vec::with_capacity(system.len() + 1);
999 messages.extend(system.iter().cloned());
1000 messages.push(serde_json::json!({
1001 "role": "user",
1002 "content": format!("{transcript}\n\n{SUMMARIZE_PROMPT}"),
1003 }));
1004 let body = serde_json::json!({
1005 "model": self.model,
1006 "messages": messages,
1007 "stream": false,
1008 });
1009
1010 let response = self
1011 .http
1012 .post(&url)
1013 .bearer_auth(&self.api_key)
1014 .json(&body)
1015 .send()
1016 .await
1017 .map_err(|error| format!("request to {url} failed: {error}"))?;
1018
1019 if !response.status().is_success() {
1020 let status = response.status();
1021 let body = response.text().await.unwrap_or_default();
1022 return Err(describe_api_error(status, &body));
1023 }
1024
1025 let parsed: ChatCompletion = response
1026 .json()
1027 .await
1028 .map_err(|error| format!("response parse error: {error}"))?;
1029 parsed
1030 .choices
1031 .into_iter()
1032 .next()
1033 .and_then(|choice| choice.message.content)
1034 .filter(|text| !text.trim().is_empty())
1035 .ok_or_else(|| "endpoint returned no summary".to_string())
1036 }
1037
1038 /// Parse a single non-streaming chat-completion response.
1039 async fn consume_json(
1040 &self,
1041 response: reqwest::Response,
1042 tools: &[ToolSpec],
1043 allow_tool_calls: bool,
1044 ) -> Result<(TurnResult, usize), BackendError> {
1045 let parsed: ChatCompletion = response
1046 .json()
1047 .await
1048 .map_err(|error| format!("response parse error: {error}"))?;
1049
1050 let choice = parsed
1051 .choices
1052 .into_iter()
1053 .next()
1054 .ok_or_else(|| "endpoint returned no choices".to_string())?;
1055 let finish = FinishReason::from_wire(choice.finish_reason.as_deref());
1056 let message = choice.message;
1057
1058 let mut text = message.content.clone().unwrap_or_default();
1059 let mut tool_calls: Vec<ToolCall> = message
1060 .tool_calls
1061 .iter()
1062 .flatten()
1063 .map(|call| ToolCall {
1064 id: call.id.clone(),
1065 name: call.function.name.clone(),
1066 arguments: call.function.arguments.clone(),
1067 })
1068 .collect();
1069
1070 let extracted = crate::inline_tool_calls::extract(&text, tools);
1071 let malformed = extracted.malformed;
1072 if malformed > 0 {
1073 log::warn!("dropped {malformed} unparseable inline tool call(s) from the reply");
1074 }
1075
1076 if !allow_tool_calls {
1077 let recovered = extracted.calls;
1078 text = extracted.text;
1079 let suppressed = tool_calls.len() + recovered.len();
1080 if suppressed > 0 {
1081 let names = tool_calls
1082 .iter()
1083 .map(|call| call.name.as_str())
1084 .chain(recovered.iter().map(|call| call.name.as_str()))
1085 .collect::<Vec<_>>()
1086 .join(", ");
1087 log::warn!(
1088 "suppressed {suppressed} tool call(s) from a forced-text response: {names}"
1089 );
1090 }
1091 self.history
1092 .lock()
1093 .await
1094 .push(streamed_assistant_history(&text, &[]));
1095 return Ok((
1096 TurnResult {
1097 text,
1098 tool_calls: Vec::new(),
1099 finish,
1100 },
1101 malformed,
1102 ));
1103 }
1104
1105 // Some models write a tool call out as literal `<tool_call>` text
1106 // instead of using the structured field (see `inline_tool_calls`).
1107 // Recover it, or the turn ends with the tag rendered as prose and
1108 // whatever the model meant to do is dropped.
1109 if tool_calls.is_empty() {
1110 let cleaned = extracted.text;
1111 let recovered = extracted.calls;
1112 if !recovered.is_empty() {
1113 log::warn!(
1114 "recovered {} tool call(s) the model emitted as text instead of a structured call",
1115 recovered.len()
1116 );
1117 let tool_calls: Vec<ToolCall> = recovered
1118 .into_iter()
1119 .enumerate()
1120 .map(|(index, call)| ToolCall {
1121 id: format!("call_recovered_{index}"),
1122 name: call.name,
1123 arguments: call.arguments,
1124 })
1125 .collect();
1126 // Record the recovered shape, not the raw tag: the tool
1127 // results that follow have to answer an assistant message
1128 // that actually carries these calls, or the next request is
1129 // rejected for orphaned tool results.
1130 self.history
1131 .lock()
1132 .await
1133 .push(streamed_assistant_history(&cleaned, &tool_calls));
1134 return Ok((
1135 TurnResult {
1136 text: cleaned,
1137 tool_calls,
1138 finish,
1139 },
1140 malformed,
1141 ));
1142 }
1143 if malformed > 0 {
1144 // Keep the broken block out of history as well as the reply;
1145 // see `inline_tool_calls` for why it poisons later turns.
1146 self.push_reply_without_calls(&cleaned).await;
1147 return Ok((
1148 TurnResult {
1149 text: cleaned,
1150 tool_calls,
1151 finish,
1152 },
1153 malformed,
1154 ));
1155 }
1156 } else if malformed > 0 || !extracted.calls.is_empty() {
1157 // Structured calls arrived with inline blocks beside them. As on
1158 // the streaming path, inline calls that parse run alongside the
1159 // structured ones, broken blocks are dropped, and neither kind of
1160 // block is left in the reply or the history.
1161 let offset = tool_calls.len();
1162 tool_calls.extend(
1163 extracted
1164 .calls
1165 .into_iter()
1166 .enumerate()
1167 .map(|(index, call)| ToolCall {
1168 id: format!("call_recovered_{}", offset + index),
1169 name: call.name,
1170 arguments: call.arguments,
1171 }),
1172 );
1173 self.history
1174 .lock()
1175 .await
1176 .push(streamed_assistant_history(&extracted.text, &tool_calls));
1177 return Ok((
1178 TurnResult {
1179 text: extracted.text,
1180 tool_calls,
1181 finish,
1182 },
1183 malformed,
1184 ));
1185 }
1186
1187 // Record the assistant turn so later tool results have context.
1188 self.history.lock().await.push(message.into_history_value());
1189
1190 Ok((
1191 TurnResult {
1192 text,
1193 tool_calls,
1194 finish,
1195 },
1196 0,
1197 ))
1198 }
1199
1200 /// Record a text-only assistant reply. An empty one is skipped: strict
1201 /// endpoints reject an assistant message with neither content nor tool
1202 /// calls, and there is nothing in it worth replaying.
1203 async fn push_reply_without_calls(&self, text: &str) {
1204 if !text.is_empty() {
1205 self.history
1206 .lock()
1207 .await
1208 .push(streamed_assistant_history(text, &[]));
1209 }
1210 }
1211
1212 /// Consume an OpenAI Server-Sent Events stream, forwarding content deltas to
1213 /// `sink` and reassembling any tool calls (which arrive fragmented across
1214 /// chunks, keyed by `index`).
1215 async fn consume_stream(
1216 &self,
1217 response: reqwest::Response,
1218 sink: &TokenSink,
1219 tools: &[ToolSpec],
1220 allow_tool_calls: bool,
1221 ) -> Result<(TurnResult, usize), BackendError> {
1222 use futures::StreamExt;
1223
1224 let mut stream = response.bytes_stream();
1225 // Newlines are ASCII, so splitting raw bytes on `\n` never bisects a
1226 // multibyte UTF-8 sequence; we only lossily decode whole lines.
1227 let mut buffer: Vec<u8> = Vec::new();
1228 let mut text = String::new();
1229 let mut tool_accum: Vec<StreamingToolCall> = Vec::new();
1230 let mut done = false;
1231 // Arrives on a late chunk, usually one with an empty delta.
1232 let mut finish = FinishReason::default();
1233 // Recovers a tool call the model wrote as literal `<tool_call>` text
1234 // instead of a structured delta. Scanning here (rather than after the
1235 // stream) keeps the tag off the UI: content goes straight to `sink` as
1236 // it arrives, so by the time a whole turn is assembled the tag has
1237 // already been rendered. See `inline_tool_calls`.
1238 let mut scanner = crate::inline_tool_calls::StreamScanner::new(tools);
1239 let mut recovered: Vec<ToolCall> = Vec::new();
1240 let mut malformed = 0usize;
1241
1242 while let Some(item) = stream.next().await {
1243 let bytes = item.map_err(|error| format!("stream read error: {error}"))?;
1244 buffer.extend_from_slice(&bytes);
1245
1246 while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
1247 let line: Vec<u8> = buffer.drain(..=pos).collect();
1248 let line = String::from_utf8_lossy(&line);
1249 let line = line.trim();
1250
1251 let Some(data) = line.strip_prefix("data:") else {
1252 continue;
1253 };
1254 let data = data.trim();
1255 if data == "[DONE]" {
1256 done = true;
1257 break;
1258 }
1259 if data.is_empty() {
1260 continue;
1261 }
1262
1263 // An endpoint that fails after the status line is already sent
1264 // reports it in the stream instead, as an ordinary `data:`
1265 // frame holding an error envelope. siGit Code Cloud does this
1266 // when the upstream fails mid-turn. It has no `choices`, so
1267 // without this it parses as an empty chunk and is skipped, and
1268 // the turn ends looking like the model simply said nothing.
1269 if let Some(message) = api_error_message(data) {
1270 return Err(message);
1271 }
1272
1273 let chunk: StreamCompletion = match serde_json::from_str(data) {
1274 Ok(chunk) => chunk,
1275 // Skip keep-alive comments and anything we can't parse rather
1276 // than aborting a turn over one malformed frame.
1277 Err(_) => continue,
1278 };
1279
1280 let Some(choice) = chunk.choices.into_iter().next() else {
1281 continue;
1282 };
1283 if let Some(reason) = choice.finish_reason.as_deref() {
1284 finish = FinishReason::from_wire(Some(reason));
1285 }
1286 if let Some(content) = choice.delta.content
1287 && !content.is_empty()
1288 {
1289 let mut cancelled = false;
1290 for event in scanner.push(&content) {
1291 match event {
1292 crate::inline_tool_calls::ScanEvent::Text(chunk) => {
1293 text.push_str(&chunk);
1294 if sink.send(chunk).is_err() {
1295 // Consumer dropped (turn cancelled).
1296 cancelled = true;
1297 break;
1298 }
1299 }
1300 crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
1301 if allow_tool_calls {
1302 log::warn!(
1303 "recovered tool call '{}' the model emitted as text instead of a structured call",
1304 call.name
1305 );
1306 }
1307 recovered.push(ToolCall {
1308 id: format!("call_recovered_{}", recovered.len()),
1309 name: call.name,
1310 arguments: call.arguments,
1311 });
1312 }
1313 crate::inline_tool_calls::ScanEvent::Malformed(block) => {
1314 log_malformed_block(&block);
1315 malformed += 1;
1316 }
1317 }
1318 }
1319 if cancelled {
1320 done = true;
1321 break;
1322 }
1323 }
1324 for delta in choice.delta.tool_calls.into_iter().flatten() {
1325 let index = delta.index.unwrap_or(0) as usize;
1326 if tool_accum.len() <= index {
1327 tool_accum.resize_with(index + 1, StreamingToolCall::default);
1328 }
1329 let slot = &mut tool_accum[index];
1330 if let Some(id) = delta.id {
1331 slot.id = id;
1332 }
1333 if let Some(function) = delta.function {
1334 if let Some(name) = function.name {
1335 slot.name = name;
1336 }
1337 if let Some(arguments) = function.arguments {
1338 slot.arguments.push_str(&arguments);
1339 }
1340 }
1341 }
1342 }
1343
1344 if done {
1345 break;
1346 }
1347 }
1348
1349 // Flush what was held back. A partial marker is just text; a
1350 // tool-call block that never closed is dropped like any other
1351 // unparseable one.
1352 match scanner.finish() {
1353 Some(crate::inline_tool_calls::ScanEvent::Text(leftover)) => {
1354 text.push_str(&leftover);
1355 let _ = sink.send(leftover);
1356 }
1357 Some(crate::inline_tool_calls::ScanEvent::Malformed(block)) => {
1358 log_malformed_block(&block);
1359 malformed += 1;
1360 }
1361 Some(crate::inline_tool_calls::ScanEvent::ToolCall(_)) | None => {}
1362 }
1363
1364 let mut tool_calls: Vec<ToolCall> = tool_accum
1365 .iter()
1366 .filter(|call| !call.name.is_empty())
1367 .enumerate()
1368 .map(|(index, call)| ToolCall {
1369 id: if call.id.is_empty() {
1370 format!("call_{index}")
1371 } else {
1372 call.id.clone()
1373 },
1374 name: call.name.clone(),
1375 arguments: call.arguments.clone(),
1376 })
1377 .collect();
1378 tool_calls.extend(recovered);
1379
1380 if !allow_tool_calls && !tool_calls.is_empty() {
1381 let names = tool_calls
1382 .iter()
1383 .map(|call| call.name.as_str())
1384 .collect::<Vec<_>>()
1385 .join(", ");
1386 log::warn!(
1387 "suppressed {} tool call(s) from a forced-text response: {names}",
1388 tool_calls.len()
1389 );
1390 tool_calls.clear();
1391 }
1392
1393 // Record the assistant turn so later tool results have context.
1394 if malformed > 0 && tool_calls.is_empty() {
1395 self.push_reply_without_calls(&text).await;
1396 } else {
1397 self.history
1398 .lock()
1399 .await
1400 .push(streamed_assistant_history(&text, &tool_calls));
1401 }
1402
1403 Ok((
1404 TurnResult {
1405 text,
1406 tool_calls,
1407 finish,
1408 },
1409 malformed,
1410 ))
1411 }
1412 }
1413
1414 /// Sent as a user turn after a reply whose tool call couldn't be parsed.
1415 const MALFORMED_TOOL_CALL_RETRY: &str = "[siGit Code] Your last reply tried to call a tool by \
1416 writing the call out as text, and it could not be parsed, so nothing ran and the user did \
1417 not see it. Do not write tool calls or tool results as text. Make the call again using the \
1418 tool-calling interface, or answer in plain prose if no tool is needed.";
1419
1420 fn log_malformed_block(block: &str) {
1421 log::warn!(
1422 "dropped an unparseable inline tool call ({} chars): {}",
1423 block.len(),
1424 block.chars().take(200).collect::<String>()
1425 );
1426 }
1427
1428 /// Join the visible text of a reply and its retry the way the stream showed
1429 /// them: as separate paragraphs.
1430 fn join_reply_text(first: &str, retry: &str) -> String {
1431 match (first.trim().is_empty(), retry.trim().is_empty()) {
1432 (true, _) => retry.to_string(),
1433 (false, true) => first.to_string(),
1434 (false, false) => format!("{first}\n\n{retry}"),
1435 }
1436 }
1437
1438 /// One tool call being reassembled from streamed deltas.
1439 #[derive(Default)]
1440 struct StreamingToolCall {
1441 id: String,
1442 name: String,
1443 arguments: String,
1444 }
1445
1446 /// Rebuild the assistant message for replay in history after a streamed turn,
1447 /// preserving any tool calls so the follow-up request is well-formed. Mirrors
1448 /// [`ResponseMessage::into_history_value`] for the non-streaming path.
1449 fn streamed_assistant_history(text: &str, tool_calls: &[ToolCall]) -> serde_json::Value {
1450 let mut message = serde_json::json!({ "role": "assistant" });
1451 message["content"] = if text.is_empty() {
1452 serde_json::Value::Null
1453 } else {
1454 serde_json::Value::String(text.to_string())
1455 };
1456 if !tool_calls.is_empty() {
1457 message["tool_calls"] = serde_json::json!(
1458 tool_calls
1459 .iter()
1460 .map(|call| serde_json::json!({
1461 "id": call.id,
1462 "type": "function",
1463 "function": {
1464 "name": call.name,
1465 "arguments": call.arguments,
1466 }
1467 }))
1468 .collect::<Vec<_>>()
1469 );
1470 }
1471 message
1472 }
1473
1474 #[async_trait]
1475 impl InferenceBackend for OpenAiBackend {
1476 async fn send_message_with_tools(
1477 &self,
1478 text: &str,
1479 tools: &[ToolSpec],
1480 sink: Option<&TokenSink>,
1481 ) -> Result<TurnResult, BackendError> {
1482 self.history
1483 .lock()
1484 .await
1485 .push(serde_json::json!({ "role": "user", "content": text }));
1486 self.complete(tools, true, sink).await
1487 }
1488
1489 async fn send_tool_results(
1490 &self,
1491 results: Vec<ToolResult>,
1492 tools: &[ToolSpec],
1493 allow_tool_calls: bool,
1494 sink: Option<&TokenSink>,
1495 ) -> Result<TurnResult, BackendError> {
1496 {
1497 let mut history = self.history.lock().await;
1498 for result in results {
1499 history.push(serde_json::json!({
1500 "role": "tool",
1501 "tool_call_id": result.tool_call_id,
1502 "content": result.content,
1503 }));
1504 }
1505 }
1506 self.complete(tools, allow_tool_calls, sink).await
1507 }
1508
1509 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
1510 let mut history = self.history.lock().await;
1511 for result in results {
1512 // Cancellation races the in-flight continuation request. That
1513 // request appends its tool results before awaiting HTTP, so cleanup
1514 // must be safe whether cancellation won just before or just after
1515 // the append.
1516 let matching_call = history.iter().rposition(|message| {
1517 message["role"] == "assistant"
1518 && message["tool_calls"].as_array().is_some_and(|calls| {
1519 calls.iter().any(|call| call["id"] == result.tool_call_id)
1520 })
1521 });
1522 let already_recorded = matching_call.is_some_and(|call_index| {
1523 history[call_index + 1..].iter().any(|message| {
1524 message["role"] == "tool" && message["tool_call_id"] == result.tool_call_id
1525 })
1526 });
1527 if already_recorded {
1528 continue;
1529 }
1530 history.push(serde_json::json!({
1531 "role": "tool",
1532 "tool_call_id": result.tool_call_id,
1533 "content": result.content,
1534 }));
1535 }
1536 }
1537
1538 async fn send_message_with_images(
1539 &self,
1540 text: &str,
1541 images: &[ImageInput],
1542 tools: &[ToolSpec],
1543 sink: Option<&TokenSink>,
1544 ) -> Result<TurnResult, BackendError> {
1545 self.history.lock().await.push(user_message(text, images));
1546 self.complete(tools, true, sink).await
1547 }
1548
1549 fn accepts_images(&self) -> bool {
1550 self.accepts_images
1551 }
1552
1553 fn is_remote(&self) -> bool {
1554 true
1555 }
1556
1557 async fn history_snapshot(&self) -> Vec<serde_json::Value> {
1558 self.history.lock().await.clone()
1559 }
1560
1561 async fn restore_history(&self, history: Vec<serde_json::Value>) {
1562 // The snapshot includes the seeded system message, so a wholesale
1563 // replacement restores exactly what was saved.
1564 *self.history.lock().await = history;
1565 }
1566
1567 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
1568 let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone();
1569
1570 // Leading system messages carry session context (the seeded prompt,
1571 // pushed context); keep them all, like the summarization-success path.
1572 let system: Vec<serde_json::Value> = snapshot
1573 .iter()
1574 .take_while(|message| message["role"] == "system")
1575 .cloned()
1576 .collect();
1577
1578 let non_system: Vec<serde_json::Value> = snapshot
1579 .iter()
1580 .filter(|message| message["role"] != "system")
1581 .cloned()
1582 .collect();
1583 let tail_start = non_system.len().saturating_sub(keep_last);
1584 let mut tail = non_system[tail_start..].to_vec();
1585 // Drop leading tool results whose assistant tool-call message was
1586 // summarized away — strict endpoints reject orphaned `role: "tool"`
1587 // entries on the very next request.
1588 while tail
1589 .first()
1590 .is_some_and(|message| message["role"] == "tool")
1591 {
1592 tail.remove(0);
1593 }
1594
1595 // Ask the endpoint for a summary of the conversation so far, through a
1596 // direct completion call. The request carries the conversation as a
1597 // flattened transcript in a single user message rather than the live
1598 // history: this round offers no tools, and a tool-shaped history sent
1599 // without a tool schema is rejected upstream (see
1600 // `transcript_for_summary`). The transcript is capped so the request
1601 // cannot be as large as the over-budget conversation that triggered
1602 // compaction (see `SUMMARY_TRANSCRIPT_TOKEN_CAP`).
1603 let transcript = truncate_transcript_middle(
1604 &transcript_for_summary(&snapshot),
1605 SUMMARY_TRANSCRIPT_TOKEN_CAP,
1606 );
1607 let summary = match self.summarize(&system, &transcript).await {
1608 Ok(summary) => summary,
1609 Err(error) => {
1610 log::warn!(
1611 "summarization round failed ({error}); falling back to plain truncation"
1612 );
1613 // Compaction must not fail the session: fold the overflow into
1614 // a placeholder and drop the oldest messages until the rebuild
1615 // fits the budget. Needs no endpoint, so it cannot fail the way
1616 // the inference round just did (issue #125).
1617 let rebuilt = truncate_history_to_budget(&system, tail.clone()).ok_or(error)?;
1618 *self.history.lock().await = rebuilt;
1619 return Ok(());
1620 }
1621 };
1622
1623 let mut rebuilt = Vec::with_capacity(system.len() + tail.len() + 1);
1624 rebuilt.extend(system);
1625 rebuilt.push(serde_json::json!({
1626 "role": "user",
1627 "content": format!("[Conversation summary]\n{summary}"),
1628 }));
1629 rebuilt.extend(tail);
1630 *self.history.lock().await = rebuilt;
1631 Ok(())
1632 }
1633 }
1634
1635 /// The placeholder message replacing dropped history when compaction falls
1636 /// back to plain truncation (the model reads it, so it explains the gap).
1637 const TRUNCATION_PLACEHOLDER: &str = "[Older conversation omitted: the session outgrew \
1638 the context window and the summarization service was unavailable. Earlier \
1639 messages were dropped, so answers may lack that context.]";
1640
1641 /// Last-resort compaction when the summarization round cannot run: rebuild the
1642 /// history as `[system, placeholder, newest messages that fit the budget]`.
1643 ///
1644 /// Returns `None` when even the smallest rebuild exceeds the budget (a
1645 /// pathological case — e.g. one message larger than the whole window); the
1646 /// caller then reports the original summarization error and leaves history
1647 /// untouched.
1648 fn truncate_history_to_budget(
1649 system: &[serde_json::Value],
1650 mut tail: Vec<serde_json::Value>,
1651 ) -> Option<Vec<serde_json::Value>> {
1652 /// A `role: "tool"` entry only makes sense behind the assistant message
1653 /// that requested it; dropping that message strands it.
1654 fn drop_orphaned_tool_results(tail: &mut Vec<serde_json::Value>) {
1655 while tail
1656 .first()
1657 .is_some_and(|message| message["role"] == "tool")
1658 {
1659 tail.remove(0);
1660 }
1661 }
1662
1663 // A caller splitting mid-round can hand in a tail that starts with tool
1664 // results whose call message already fell outside the kept window.
1665 drop_orphaned_tool_results(&mut tail);
1666
1667 loop {
1668 let mut candidate = Vec::with_capacity(tail.len() + system.len() + 1);
1669 candidate.extend(system.iter().cloned());
1670 candidate.push(serde_json::json!({
1671 "role": "user",
1672 "content": TRUNCATION_PLACEHOLDER,
1673 }));
1674 candidate.extend(tail.iter().cloned());
1675 if estimate_tokens(&candidate) <= DEFAULT_CONTEXT_TOKEN_BUDGET {
1676 return Some(candidate);
1677 }
1678
1679 // Nothing left to drop: even the minimal rebuild (system + placeholder,
1680 // no conversation) overflows — the session is genuinely unrecoverable.
1681 if tail.is_empty() {
1682 return None;
1683 }
1684 if tail[0]["role"] == "assistant"
1685 && tail[0]["tool_calls"]
1686 .as_array()
1687 .is_some_and(|calls| !calls.is_empty())
1688 && tail.get(1).is_some_and(|message| message["role"] == "tool")
1689 {
1690 // Dropping an assistant tool-call message would orphan its
1691 // results; drop the whole round (call + results) together.
1692 let mut end = 1;
1693 while tail
1694 .get(end)
1695 .is_some_and(|message| message["role"] == "tool")
1696 {
1697 end += 1;
1698 }
1699 tail.drain(..end);
1700 } else {
1701 tail.remove(0);
1702 drop_orphaned_tool_results(&mut tail);
1703 }
1704 }
1705 }
1706
1707 // ── OpenAI error shape ────────────────────────────────────────────────────────
1708
1709 /// The OpenAI error envelope: `{"error": {"message": ..., "type": ...}}`.
1710 ///
1711 /// Every endpoint siGit talks to speaks it, including siGit Code Cloud, whose
1712 /// messages are written for the person reading them ("Monthly siGit Code Cloud
1713 /// allowance reached..."). Worth unwrapping rather than pasting the raw body
1714 /// into the editor's error banner.
1715 #[derive(Debug, Deserialize)]
1716 struct ApiErrorEnvelope {
1717 error: ApiErrorBody,
1718 }
1719
1720 #[derive(Debug, Deserialize)]
1721 struct ApiErrorBody {
1722 #[serde(default)]
1723 message: Option<String>,
1724 }
1725
1726 /// How much of an unparseable error body to keep. An endpoint behind a proxy
1727 /// can answer with a full HTML page, and the whole thing ends up in the
1728 /// editor's error banner.
1729 const ERROR_BODY_LIMIT: usize = 500;
1730
1731 /// Turn an error response into something worth showing a person.
1732 ///
1733 /// The endpoint's own message wins when there is one: it is written for the
1734 /// user, and the status code repeats what it already says. Anything else falls
1735 /// back to the status plus whatever the body held, which is all there is to go
1736 /// on — except an HTML page, which is a proxy or Rails error page and tells
1737 /// the person nothing the status doesn't (pasting it into chat is the dump
1738 /// seen in issue #125's screenshots).
1739 fn describe_api_error(status: reqwest::StatusCode, body: &str) -> String {
1740 if let Some(message) = api_error_message(body) {
1741 return message;
1742 }
1743
1744 let body = body.trim();
1745 if body.is_empty() || is_probable_html(body) {
1746 return format!("endpoint returned {status}");
1747 }
1748
1749 let mut detail = body;
1750 if detail.len() > ERROR_BODY_LIMIT {
1751 let mut cut = ERROR_BODY_LIMIT;
1752 while !detail.is_char_boundary(cut) {
1753 cut -= 1;
1754 }
1755 detail = &detail[..cut];
1756 return format!("endpoint returned {status}: {detail}…");
1757 }
1758 format!("endpoint returned {status}: {detail}")
1759 }
1760
1761 /// The human-readable message out of an OpenAI error envelope, if the body is
1762 /// one and carries a non-empty message.
1763 fn api_error_message(body: &str) -> Option<String> {
1764 let envelope: ApiErrorEnvelope = serde_json::from_str(body).ok()?;
1765 let message = envelope.error.message?;
1766 let message = message.trim();
1767 (!message.is_empty()).then(|| message.to_string())
1768 }
1769
1770 /// Whether `body` looks like an HTML page rather than prose: a doctype or an
1771 /// opening tag in the first chunk. Gateways and Rails apps answer failures
1772 /// with full pages; none of that markup is worth showing.
1773 fn is_probable_html(body: &str) -> bool {
1774 let head: String = body.chars().take(512).collect();
1775 let head = head.trim_start().to_ascii_lowercase();
1776 head.starts_with("<!doctype") || head.starts_with("<html") || head.starts_with("<head")
1777 }
1778
1779 // ── OpenAI response shapes ────────────────────────────────────────────────────────
1780
1781 #[derive(Debug, Deserialize)]
1782 struct ChatCompletion {
1783 #[serde(default)]
1784 choices: Vec<CompletionChoice>,
1785 }
1786
1787 #[derive(Debug, Deserialize)]
1788 struct CompletionChoice {
1789 message: ResponseMessage,
1790 #[serde(default)]
1791 finish_reason: Option<String>,
1792 }
1793
1794 #[derive(Debug, Deserialize)]
1795 struct ResponseMessage {
1796 #[serde(default)]
1797 content: Option<String>,
1798 #[serde(default)]
1799 tool_calls: Option<Vec<ResponseToolCall>>,
1800 }
1801
1802 impl ResponseMessage {
1803 /// Reconstruct the assistant message for replay in history, preserving any
1804 /// tool calls so the follow-up request is well-formed.
1805 fn into_history_value(self) -> serde_json::Value {
1806 let mut message = serde_json::json!({ "role": "assistant" });
1807 message["content"] = match self.content {
1808 Some(text) => serde_json::Value::String(text),
1809 None => serde_json::Value::Null,
1810 };
1811 if let Some(tool_calls) = self.tool_calls {
1812 message["tool_calls"] = serde_json::json!(
1813 tool_calls
1814 .into_iter()
1815 .map(|call| serde_json::json!({
1816 "id": call.id,
1817 "type": "function",
1818 "function": {
1819 "name": call.function.name,
1820 "arguments": call.function.arguments,
1821 }
1822 }))
1823 .collect::<Vec<_>>()
1824 );
1825 }
1826 message
1827 }
1828 }
1829
1830 #[derive(Debug, Deserialize)]
1831 struct ResponseToolCall {
1832 id: String,
1833 function: ResponseFunction,
1834 }
1835
1836 #[derive(Debug, Deserialize)]
1837 struct ResponseFunction {
1838 name: String,
1839 #[serde(default)]
1840 arguments: String,
1841 }
1842
1843 // ── OpenAI streaming (SSE) chunk shapes ─────────────────────────────────────────
1844
1845 #[derive(Debug, Deserialize)]
1846 struct StreamCompletion {
1847 #[serde(default)]
1848 choices: Vec<StreamChoice>,
1849 }
1850
1851 #[derive(Debug, Deserialize)]
1852 struct StreamChoice {
1853 #[serde(default)]
1854 delta: StreamDelta,
1855 #[serde(default)]
1856 finish_reason: Option<String>,
1857 }
1858
1859 #[derive(Debug, Default, Deserialize)]
1860 struct StreamDelta {
1861 #[serde(default)]
1862 content: Option<String>,
1863 #[serde(default)]
1864 tool_calls: Option<Vec<StreamToolCallDelta>>,
1865 }
1866
1867 #[derive(Debug, Deserialize)]
1868 struct StreamToolCallDelta {
1869 #[serde(default)]
1870 index: Option<u32>,
1871 #[serde(default)]
1872 id: Option<String>,
1873 #[serde(default)]
1874 function: Option<StreamFunctionDelta>,
1875 }
1876
1877 #[derive(Debug, Deserialize)]
1878 struct StreamFunctionDelta {
1879 #[serde(default)]
1880 name: Option<String>,
1881 #[serde(default)]
1882 arguments: Option<String>,
1883 }
1884
1885 // ── Carrying a conversation across a model switch ────────────────────────────
1886
1887 /// The part of a history snapshot that survives a model switch: every
1888 /// non-system message, with half-finished tool plumbing repaired.
1889 ///
1890 /// System messages are dropped because the backend being switched *to* seeds
1891 /// its own — a different model's prompt, or a freshly pushed session-context
1892 /// message. A switch can land mid-turn, after the assistant asked for a tool
1893 /// but before the results came back; a tool call with no matching result (or a
1894 /// result with no matching call) makes strict OpenAI-compatible endpoints
1895 /// reject every later request in the session, so those halves are stripped
1896 /// instead of carried.
1897 pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
1898 let answered: std::collections::HashSet<String> = snapshot
1899 .iter()
1900 .filter(|message| message["role"] == "tool")
1901 .filter_map(|message| message["tool_call_id"].as_str().map(str::to_string))
1902 .collect();
1903
1904 let mut carried: Vec<serde_json::Value> = Vec::with_capacity(snapshot.len());
1905 let mut called: std::collections::HashSet<String> = std::collections::HashSet::new();
1906
1907 for mut message in snapshot {
1908 match message["role"].as_str().unwrap_or_default() {
1909 "system" => continue,
1910 "assistant" => {
1911 if let Some(calls) = message["tool_calls"].as_array() {
1912 let kept: Vec<serde_json::Value> = calls
1913 .iter()
1914 .filter(|call| call["id"].as_str().is_some_and(|id| answered.contains(id)))
1915 .cloned()
1916 .collect();
1917 if kept.is_empty() {
1918 // Only unanswered requests: keep whatever text came
1919 // with them, drop the message if there was none.
1920 if let Some(object) = message.as_object_mut() {
1921 object.remove("tool_calls");
1922 }
1923 if message_text(&message).is_empty() {
1924 continue;
1925 }
1926 } else {
1927 for call in &kept {
1928 called.insert(call["id"].as_str().unwrap_or_default().to_string());
1929 }
1930 message["tool_calls"] = serde_json::Value::Array(kept);
1931 }
1932 }
1933 carried.push(message);
1934 }
1935 "tool" => {
1936 let answers_a_kept_call = message["tool_call_id"]
1937 .as_str()
1938 .is_some_and(|id| called.contains(id));
1939 if answers_a_kept_call {
1940 carried.push(message);
1941 }
1942 }
1943 _ => carried.push(message),
1944 }
1945 }
1946
1947 // Session switches happen only after the serialized turn has ended. A
1948 // trailing user message therefore belongs to a cancelled/failed inference
1949 // request with no answer; carrying it would make the next activation feed
1950 // the model an orphaned prompt that the client considers cancelled.
1951 while carried
1952 .last()
1953 .is_some_and(|message| message["role"] == "user")
1954 {
1955 carried.pop();
1956 }
1957
1958 carried
1959 }
1960
1961 /// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
1962 /// system messages `backend` seeded for itself. Used when a model switch
1963 /// installs a new backend — or reloads the on-device engine, which wipes its
1964 /// history — so the thread continues under the new model instead of restarting.
1965 pub async fn adopt_carryover(backend: &dyn InferenceBackend, carried: Vec<serde_json::Value>) {
1966 if carried.is_empty() {
1967 return;
1968 }
1969 let mut rebuilt: Vec<serde_json::Value> = backend
1970 .history_snapshot()
1971 .await
1972 .into_iter()
1973 .take_while(|message| message["role"] == "system")
1974 .collect();
1975 rebuilt.extend(carried);
1976 backend.restore_history(rebuilt).await;
1977 }
1978
1979 #[cfg(test)]
1980 mod tests {
1981 use super::*;
1982
1983 /// The case from the issue: the endpoint says something a person can act
1984 /// on, and the status code and JSON around it are noise.
1985 #[test]
1986 fn an_endpoints_own_message_is_what_the_user_sees() {
1987 let body = r#"{"error":{"message":"Monthly siGit Code Cloud allowance reached. It resets at the start of your next billing period.","type":"server_error"}}"#;
1988
1989 let described = describe_api_error(reqwest::StatusCode::TOO_MANY_REQUESTS, body);
1990
1991 assert_eq!(
1992 described,
1993 "Monthly siGit Code Cloud allowance reached. \
1994 It resets at the start of your next billing period."
1995 );
1996 }
1997
1998 #[test]
1999 fn a_body_that_is_not_an_error_envelope_keeps_the_status() {
2000 let described = describe_api_error(reqwest::StatusCode::BAD_GATEWAY, "upstream said no");
2001
2002 assert!(described.contains("502"), "{described}");
2003 assert!(described.contains("upstream said no"), "{described}");
2004 }
2005
2006 /// The issue-#125 screenshots: a gateway's HTML error page pasted into
2007 /// chat tells the person nothing the status doesn't already say.
2008 #[test]
2009 fn an_html_error_page_is_not_pasted_into_the_message() {
2010 let page = "<!doctype html>\n<html lang=\"en\">\n<head>\n\
2011 <title>We're sorry, but something went wrong (500 Internal Server Error)</title>\n\
2012 </head>\n<body>…</body>\n</html>";
2013
2014 let described = describe_api_error(reqwest::StatusCode::GATEWAY_TIMEOUT, page);
2015
2016 assert_eq!(described, "endpoint returned 504 Gateway Timeout");
2017 }
2018
2019 #[test]
2020 fn an_empty_body_still_says_something() {
2021 let described = describe_api_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, " ");
2022
2023 assert_eq!(described, "endpoint returned 500 Internal Server Error");
2024 }
2025
2026 #[test]
2027 fn an_oversized_body_is_cut_down() {
2028 let body = "x".repeat(ERROR_BODY_LIMIT * 3);
2029
2030 let described = describe_api_error(reqwest::StatusCode::BAD_GATEWAY, &body);
2031
2032 assert!(described.len() < ERROR_BODY_LIMIT + 100, "{described}");
2033 assert!(described.ends_with('…'), "{described}");
2034 }
2035
2036 /// An envelope with nothing useful in it must not shadow the status, which
2037 /// would leave the user with an empty error.
2038 #[test]
2039 fn an_envelope_with_a_blank_message_falls_back() {
2040 let described = describe_api_error(
2041 reqwest::StatusCode::FORBIDDEN,
2042 r#"{"error":{"message":" "}}"#,
2043 );
2044
2045 assert!(described.contains("403"), "{described}");
2046 }
2047
2048 #[test]
2049 fn tools_json_wraps_function_schema() {
2050 let tools = vec![ToolSpec {
2051 name: "read_file".to_string(),
2052 description: "Read a file".to_string(),
2053 parameters_schema: r#"{"type":"object","properties":{"path":{"type":"string"}}}"#
2054 .to_string(),
2055 }];
2056 let json = OpenAiBackend::tools_json(&tools);
2057 assert_eq!(json[0]["type"], "function");
2058 assert_eq!(json[0]["function"]["name"], "read_file");
2059 assert_eq!(
2060 json[0]["function"]["parameters"]["properties"]["path"]["type"],
2061 "string"
2062 );
2063 }
2064
2065 #[test]
2066 fn malformed_schema_falls_back_to_empty_object() {
2067 let tools = vec![ToolSpec {
2068 name: "x".to_string(),
2069 description: String::new(),
2070 parameters_schema: "not json".to_string(),
2071 }];
2072 let json = OpenAiBackend::tools_json(&tools);
2073 assert_eq!(json[0]["function"]["parameters"]["type"], "object");
2074 }
2075
2076 #[test]
2077 fn streamed_assistant_history_omits_empty_tool_calls() {
2078 let value = streamed_assistant_history("hello", &[]);
2079 assert_eq!(value["role"], "assistant");
2080 assert_eq!(value["content"], "hello");
2081 assert!(value.get("tool_calls").is_none());
2082 }
2083
2084 #[test]
2085 fn streamed_assistant_history_preserves_tool_calls() {
2086 let calls = vec![ToolCall {
2087 id: "call_0".to_string(),
2088 name: "read_file".to_string(),
2089 arguments: r#"{"path":"a.rs"}"#.to_string(),
2090 }];
2091 let value = streamed_assistant_history("", &calls);
2092 assert!(value["content"].is_null());
2093 assert_eq!(value["tool_calls"][0]["id"], "call_0");
2094 assert_eq!(value["tool_calls"][0]["type"], "function");
2095 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
2096 assert_eq!(
2097 value["tool_calls"][0]["function"]["arguments"],
2098 r#"{"path":"a.rs"}"#
2099 );
2100 }
2101
2102 #[tokio::test]
2103 async fn cancelled_tool_results_close_out_history() {
2104 let backend = OpenAiBackend::new("http://localhost", "", "test-model", None);
2105 backend
2106 .history
2107 .lock()
2108 .await
2109 .push(streamed_assistant_history(
2110 "",
2111 &[ToolCall {
2112 id: "call_9".to_string(),
2113 name: "run_command".to_string(),
2114 arguments: r#"{"command":"ls"}"#.to_string(),
2115 }],
2116 ));
2117
2118 backend
2119 .record_cancelled_tool_results(vec![ToolResult {
2120 tool_call_id: "call_9".to_string(),
2121 content: "cancelled by the user".to_string(),
2122 }])
2123 .await;
2124
2125 let history = backend.history.lock().await;
2126 let last = history.last().unwrap();
2127 assert_eq!(last["role"], "tool");
2128 assert_eq!(last["tool_call_id"], "call_9");
2129 assert_eq!(last["content"], "cancelled by the user");
2130
2131 drop(history);
2132 backend
2133 .record_cancelled_tool_results(vec![ToolResult {
2134 tool_call_id: "call_9".to_string(),
2135 content: "duplicate cleanup".to_string(),
2136 }])
2137 .await;
2138 let history = backend.history.lock().await;
2139 assert_eq!(
2140 history
2141 .iter()
2142 .filter(|message| message["tool_call_id"] == "call_9")
2143 .count(),
2144 1
2145 );
2146 }
2147
2148 #[test]
2149 fn carryover_drops_system_messages_and_keeps_the_turns() {
2150 let snapshot = vec![
2151 serde_json::json!({ "role": "system", "content": "old prompt" }),
2152 serde_json::json!({ "role": "user", "content": "hello" }),
2153 serde_json::json!({ "role": "assistant", "content": "hi" }),
2154 ];
2155
2156 let carried = carryover_history(snapshot);
2157
2158 assert_eq!(carried.len(), 2);
2159 assert_eq!(carried[0]["role"], "user");
2160 assert_eq!(carried[1]["content"], "hi");
2161 }
2162
2163 #[test]
2164 fn carryover_drops_a_cancelled_trailing_user_message() {
2165 let carried = carryover_history(vec![
2166 serde_json::json!({ "role": "system", "content": "prompt" }),
2167 serde_json::json!({ "role": "user", "content": "completed question" }),
2168 serde_json::json!({ "role": "assistant", "content": "completed answer" }),
2169 serde_json::json!({ "role": "user", "content": "cancelled question" }),
2170 ]);
2171
2172 assert_eq!(carried.len(), 2, "{carried:#?}");
2173 assert_eq!(carried.last().unwrap()["content"], "completed answer");
2174 }
2175
2176 #[test]
2177 fn carryover_strips_a_tool_call_that_never_got_a_result() {
2178 // Switching mid-turn: the assistant asked for a tool, nothing answered.
2179 let snapshot = vec![
2180 serde_json::json!({ "role": "user", "content": "read a.rs" }),
2181 streamed_assistant_history(
2182 "on it",
2183 &[ToolCall {
2184 id: "call_1".to_string(),
2185 name: "read_file".to_string(),
2186 arguments: "{}".to_string(),
2187 }],
2188 ),
2189 ];
2190
2191 let carried = carryover_history(snapshot);
2192
2193 assert_eq!(carried.len(), 2);
2194 assert_eq!(carried[1]["content"], "on it");
2195 assert!(
2196 carried[1].get("tool_calls").is_none(),
2197 "an unanswered tool call must not survive the switch"
2198 );
2199 }
2200
2201 #[test]
2202 fn carryover_drops_a_textless_unanswered_tool_call_and_its_late_result() {
2203 let snapshot = vec![
2204 streamed_assistant_history(
2205 "",
2206 &[ToolCall {
2207 id: "call_1".to_string(),
2208 name: "read_file".to_string(),
2209 arguments: "{}".to_string(),
2210 }],
2211 ),
2212 // An orphan: its assistant message is gone with the line above.
2213 serde_json::json!({
2214 "role": "tool",
2215 "tool_call_id": "call_other",
2216 "content": "file contents",
2217 }),
2218 ];
2219
2220 assert!(carryover_history(snapshot).is_empty());
2221 }
2222
2223 #[test]
2224 fn carryover_keeps_an_answered_tool_call_paired_with_its_result() {
2225 let snapshot = vec![
2226 streamed_assistant_history(
2227 "",
2228 &[ToolCall {
2229 id: "call_1".to_string(),
2230 name: "read_file".to_string(),
2231 arguments: "{}".to_string(),
2232 }],
2233 ),
2234 serde_json::json!({
2235 "role": "tool",
2236 "tool_call_id": "call_1",
2237 "content": "file contents",
2238 }),
2239 ];
2240
2241 let carried = carryover_history(snapshot);
2242
2243 assert_eq!(carried.len(), 2);
2244 assert_eq!(carried[0]["tool_calls"][0]["id"], "call_1");
2245 assert_eq!(carried[1]["tool_call_id"], "call_1");
2246 }
2247
2248 #[tokio::test]
2249 async fn adopt_carryover_replays_the_thread_under_the_new_system_prompt() {
2250 let new_backend =
2251 OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
2252
2253 let carried = carryover_history(vec![
2254 serde_json::json!({ "role": "system", "content": "old prompt" }),
2255 serde_json::json!({ "role": "user", "content": "hello" }),
2256 serde_json::json!({ "role": "assistant", "content": "hi" }),
2257 ]);
2258 adopt_carryover(&new_backend, carried).await;
2259
2260 let history = new_backend.history_snapshot().await;
2261 assert_eq!(history.len(), 3);
2262 assert_eq!(history[0]["role"], "system");
2263 assert_eq!(history[0]["content"], "new prompt");
2264 assert_eq!(history[1]["content"], "hello");
2265 assert_eq!(history[2]["content"], "hi");
2266 }
2267
2268 #[tokio::test]
2269 async fn adopt_carryover_leaves_a_fresh_backend_alone() {
2270 let new_backend =
2271 OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
2272
2273 adopt_carryover(&new_backend, Vec::new()).await;
2274
2275 let history = new_backend.history_snapshot().await;
2276 assert_eq!(history.len(), 1);
2277 assert_eq!(history[0]["content"], "new prompt");
2278 }
2279
2280 fn user_with_image(text: &str, payload_chars: usize) -> serde_json::Value {
2281 serde_json::json!({
2282 "role": "user",
2283 "content": [
2284 { "type": "text", "text": text },
2285 { "type": "image_url", "image_url": {
2286 "url": format!("data:image/png;base64,{}", "A".repeat(payload_chars)),
2287 }},
2288 ],
2289 })
2290 }
2291
2292 #[test]
2293 fn user_message_uses_content_parts_only_when_there_is_an_image() {
2294 assert_eq!(
2295 user_message("hello", &[]),
2296 serde_json::json!({ "role": "user", "content": "hello" })
2297 );
2298
2299 let image = ImageInput {
2300 mime_type: "image/png".to_string(),
2301 data: "AAAA".to_string(),
2302 };
2303 assert_eq!(
2304 user_message("what is this?", std::slice::from_ref(&image)),
2305 serde_json::json!({
2306 "role": "user",
2307 "content": [
2308 { "type": "text", "text": "what is this?" },
2309 { "type": "image_url", "image_url": { "url": "data:image/png;base64,AAAA" } },
2310 ],
2311 })
2312 );
2313 // An image with no text sends no empty text part.
2314 let only_image = user_message("", &[image]);
2315 assert_eq!(only_image["content"].as_array().unwrap().len(), 1);
2316 }
2317
2318 #[test]
2319 fn a_text_only_model_is_sent_a_note_in_place_of_each_image() {
2320 let history = vec![
2321 serde_json::json!({ "role": "system", "content": "prompt" }),
2322 user_with_image("what is this?", 16),
2323 serde_json::json!({ "role": "assistant", "content": "a cat" }),
2324 ];
2325 let sent = without_images(&history);
2326
2327 assert_eq!(sent[0], history[0]);
2328 assert_eq!(sent[2], history[2]);
2329 assert_eq!(
2330 sent[1]["content"],
2331 format!("what is this?\n{IMAGE_OMITTED_NOTE}")
2332 );
2333 // History keeps the image for a model that can read it later.
2334 assert_eq!(message_image_count(&history[1]), 1);
2335 }
2336
2337 #[test]
2338 fn a_remote_backend_reads_images_by_model_id() {
2339 let own_endpoint = OpenAiBackend::new("http://localhost", "", "gpt-4o-mini", None);
2340 assert!(own_endpoint.accepts_images());
2341 let text_tier = OpenAiBackend::new("http://localhost", "", "onde-nova", None);
2342 assert!(!text_tier.accepts_images());
2343 let image_tier = OpenAiBackend::new("http://localhost", "", "onde-large", None);
2344 assert!(image_tier.accepts_images());
2345 }
2346
2347 #[test]
2348 fn message_text_reads_both_content_shapes() {
2349 assert_eq!(
2350 message_text(&serde_json::json!({ "role": "user", "content": "hello" })),
2351 "hello"
2352 );
2353 let with_image = user_with_image("what is this?", 16);
2354 assert_eq!(message_text(&with_image), "what is this?");
2355 assert_eq!(message_image_count(&with_image), 1);
2356 // A tool-call-only assistant message has no content at all.
2357 let no_content = serde_json::json!({ "role": "assistant", "content": null });
2358 assert_eq!(message_text(&no_content), "");
2359 assert_eq!(message_image_count(&no_content), 0);
2360 }
2361
2362 #[test]
2363 fn estimate_tokens_does_not_count_an_image_payload_as_text() {
2364 // Two megabytes of base64 would read as half a million tokens.
2365 let history = vec![user_with_image("what is this?", 2_000_000)];
2366 let estimate = estimate_tokens(&history);
2367 assert!(
2368 (IMAGE_TOKEN_ESTIMATE..IMAGE_TOKEN_ESTIMATE + 100).contains(&estimate),
2369 "{estimate}"
2370 );
2371 }
2372
2373 #[test]
2374 fn summary_transcript_keeps_the_text_of_a_message_with_an_image() {
2375 let transcript = transcript_for_summary(&[user_with_image("what is this?", 64)]);
2376 assert!(transcript.contains("what is this?"), "{transcript}");
2377 assert!(transcript.contains("[attached an image]"), "{transcript}");
2378 assert!(!transcript.contains("base64"), "{transcript}");
2379 }
2380
2381 #[test]
2382 fn carryover_keeps_a_user_message_that_carries_an_image() {
2383 let carried = carryover_history(vec![
2384 serde_json::json!({ "role": "system", "content": "prompt" }),
2385 user_with_image("what is this?", 16),
2386 serde_json::json!({ "role": "assistant", "content": "a cat" }),
2387 ]);
2388 assert_eq!(carried.len(), 2);
2389 assert_eq!(message_image_count(&carried[0]), 1);
2390 }
2391
2392 #[test]
2393 fn estimate_tokens_scales_with_serialized_size() {
2394 assert_eq!(estimate_tokens(&[]), 0);
2395
2396 let short = vec![serde_json::json!({ "role": "user", "content": "hi" })];
2397 let long = vec![serde_json::json!({ "role": "user", "content": "x".repeat(4_000) })];
2398 let short_estimate = estimate_tokens(&short);
2399 let long_estimate = estimate_tokens(&long);
2400
2401 assert!(short_estimate > 0, "non-empty history estimates > 0 tokens");
2402 assert!(long_estimate > short_estimate, "longer history costs more");
2403 // 4,000 content chars / 4 ≈ 1,000 tokens, plus a little JSON framing.
2404 assert!((1_000..1_100).contains(&long_estimate), "{long_estimate}");
2405 }
2406
2407 #[tokio::test]
2408 async fn openai_snapshot_restore_round_trips_exactly() {
2409 let backend = OpenAiBackend::new("http://localhost", "", "m", Some("be helpful".into()));
2410 {
2411 let mut history = backend.history.lock().await;
2412 history.push(serde_json::json!({ "role": "user", "content": "hello" }));
2413 history.push(streamed_assistant_history(
2414 "",
2415 &[ToolCall {
2416 id: "call_1".to_string(),
2417 name: "read_file".to_string(),
2418 arguments: r#"{"path":"a.rs"}"#.to_string(),
2419 }],
2420 ));
2421 history.push(serde_json::json!({
2422 "role": "tool", "tool_call_id": "call_1", "content": "fn main() {}",
2423 }));
2424 history.push(serde_json::json!({ "role": "assistant", "content": "done" }));
2425 }
2426 let snapshot = backend.history_snapshot().await;
2427 assert_eq!(
2428 snapshot[0]["role"], "system",
2429 "snapshot keeps the system message"
2430 );
2431
2432 // Restoring into a backend seeded with a *different* system prompt must
2433 // replace everything, including that seed.
2434 let restored = OpenAiBackend::new("http://localhost", "", "m", Some("other seed".into()));
2435 restored.restore_history(snapshot.clone()).await;
2436 assert_eq!(restored.history_snapshot().await, snapshot);
2437 }
2438
2439 #[tokio::test]
2440 async fn local_restore_replaces_engine_history_and_system_context() {
2441 let engine = Arc::new(ChatEngine::new());
2442 let backend = LocalBackend::new(Arc::clone(&engine));
2443 backend
2444 .restore_history(vec![
2445 serde_json::json!({ "role": "system", "content": "thread A context" }),
2446 serde_json::json!({ "role": "user", "content": "thread A question" }),
2447 ])
2448 .await;
2449
2450 backend
2451 .restore_history(vec![
2452 serde_json::json!({ "role": "system", "content": "thread B context" }),
2453 serde_json::json!({ "role": "user", "content": "thread B question" }),
2454 ])
2455 .await;
2456
2457 let restored = backend.history_snapshot().await;
2458 assert_eq!(restored.len(), 2, "{restored:#?}");
2459 assert_eq!(restored[0]["content"], "thread B context");
2460 assert_eq!(restored[1]["content"], "thread B question");
2461 assert!(
2462 restored.iter().all(|message| !message["content"]
2463 .as_str()
2464 .unwrap_or_default()
2465 .contains("thread A")),
2466 "restore_history must replace, not append to, the local engine: {restored:#?}"
2467 );
2468
2469 let error = backend
2470 .send_message_with_tools("must not run", &[], None)
2471 .await
2472 .unwrap_err();
2473 assert!(error.contains("not ready"), "{error}");
2474 assert_eq!(backend.history_snapshot().await, restored);
2475 }
2476
2477 /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
2478 /// a std listener and answers with a fixed non-streaming completion. The
2479 /// receiver yields the request body the backend actually put on the wire.
2480 fn spawn_completion_stub(
2481 summary: &str,
2482 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2483 spawn_message_stub(serde_json::json!({ "role": "assistant", "content": summary }))
2484 }
2485
2486 /// Like [`spawn_completion_stub`], answering with an arbitrary message.
2487 fn spawn_message_stub(
2488 message: serde_json::Value,
2489 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2490 spawn_choice_stub(serde_json::json!({ "message": message }))
2491 }
2492
2493 /// Like [`spawn_message_stub`], answering with a whole choice, so a test
2494 /// can set what sits beside the message.
2495 fn spawn_choice_stub(
2496 choice: serde_json::Value,
2497 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
2498 use std::io::{Read, Write};
2499
2500 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2501 let addr = listener.local_addr().unwrap();
2502 let (sender, receiver) = std::sync::mpsc::channel();
2503 let body = serde_json::json!({ "choices": [choice] }).to_string();
2504 std::thread::spawn(move || {
2505 let (mut stream, _) = listener.accept().unwrap();
2506 // Read until the full request (headers + content-length body) is in.
2507 let mut request = Vec::new();
2508 let mut chunk = [0u8; 4096];
2509 loop {
2510 let n = stream.read(&mut chunk).unwrap_or(0);
2511 if n == 0 {
2512 break;
2513 }
2514 request.extend_from_slice(&chunk[..n]);
2515 if let Some(headers_end) =
2516 request.windows(4).position(|window| window == b"\r\n\r\n")
2517 {
2518 let headers = String::from_utf8_lossy(&request[..headers_end]);
2519 let content_length = headers
2520 .lines()
2521 .find_map(|line| {
2522 line.to_ascii_lowercase()
2523 .strip_prefix("content-length:")
2524 .map(|value| value.trim().parse::<usize>().unwrap_or(0))
2525 })
2526 .unwrap_or(0);
2527 if request.len() >= headers_end + 4 + content_length {
2528 let _ = sender.send(
2529 String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
2530 );
2531 break;
2532 }
2533 }
2534 }
2535 let response = format!(
2536 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
2537 content-length: {}\r\nconnection: close\r\n\r\n{}",
2538 body.len(),
2539 body
2540 );
2541 let _ = stream.write_all(response.as_bytes());
2542 });
2543 (addr, receiver)
2544 }
2545
2546 #[test]
2547 fn finish_reasons_map_from_the_wire() {
2548 for reason in [None, Some("stop"), Some("tool_calls"), Some("unheard-of")] {
2549 assert_eq!(FinishReason::from_wire(reason), FinishReason::Complete);
2550 }
2551 assert_eq!(
2552 FinishReason::from_wire(Some("length")),
2553 FinishReason::Length
2554 );
2555 assert_eq!(
2556 FinishReason::from_wire(Some("content_filter")),
2557 FinishReason::ContentFilter
2558 );
2559 }
2560
2561 /// The non-streaming path (headless runs, subagents) reads the reason off
2562 /// the choice; a reply cut off at the token limit must not look finished.
2563 #[tokio::test]
2564 async fn a_truncated_json_reply_reports_length() {
2565 let (addr, _requests) = spawn_choice_stub(serde_json::json!({
2566 "message": { "role": "assistant", "content": "The answer is" },
2567 "finish_reason": "length",
2568 }));
2569 let backend =
2570 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
2571
2572 let result = backend
2573 .send_message_with_tools("explain", &[], None)
2574 .await
2575 .unwrap();
2576
2577 assert_eq!(result.text, "The answer is");
2578 assert_eq!(result.finish, FinishReason::Length);
2579 }
2580
2581 /// A structured call can come back with a broken inline block in the same
2582 /// message. The call runs; the block reaches neither the reply nor the
2583 /// history, where the model would read back a call that never ran.
2584 #[tokio::test]
2585 async fn a_broken_inline_block_beside_a_structured_call_is_dropped() {
2586 let (addr, _requests) = spawn_message_stub(serde_json::json!({
2587 "role": "assistant",
2588 "content": "Checking.<tool_call>run_command CheckStatus=nope</tool_call>",
2589 "tool_calls": [{
2590 "id": "call_1",
2591 "type": "function",
2592 "function": { "name": "run_command", "arguments": "{\"command\":\"pwd\"}" },
2593 }],
2594 }));
2595 let backend =
2596 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
2597 let tools = vec![ToolSpec {
2598 name: "run_command".to_string(),
2599 description: "Run a command".to_string(),
2600 parameters_schema: r#"{"type":"object","properties":{"command":{"type":"string"}}}"#
2601 .to_string(),
2602 }];
2603
2604 let result = backend
2605 .send_message_with_tools("where am I", &tools, None)
2606 .await
2607 .unwrap();
2608
2609 assert_eq!(result.text, "Checking.");
2610 assert_eq!(result.tool_calls.len(), 1);
2611 assert_eq!(result.tool_calls[0].id, "call_1");
2612 let history = backend.history_snapshot().await;
2613 let reply = history.last().unwrap();
2614 assert_eq!(reply["content"], "Checking.");
2615 assert_eq!(reply["tool_calls"][0]["id"], "call_1");
2616 }
2617
2618 /// A structured call plus a well-formed inline block: both run, and the
2619 /// inline markup leaves the reply and history, as on the streaming path.
2620 #[tokio::test]
2621 async fn a_well_formed_inline_block_beside_a_structured_call_also_runs() {
2622 let (addr, _requests) = spawn_message_stub(serde_json::json!({
2623 "role": "assistant",
2624 "content": "Checking.<tool_call>run_command<arg_key>command</arg_key><arg_value>ls</arg_value></tool_call>",
2625 "tool_calls": [{
2626 "id": "call_1",
2627 "type": "function",
2628 "function": { "name": "run_command", "arguments": "{\"command\":\"pwd\"}" },
2629 }],
2630 }));
2631 let backend =
2632 OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
2633 let tools = vec![ToolSpec {
2634 name: "run_command".to_string(),
2635 description: "Run a command".to_string(),
2636 parameters_schema: r#"{"type":"object","properties":{"command":{"type":"string"}}}"#
2637 .to_string(),
2638 }];
2639
2640 let result = backend
2641 .send_message_with_tools("where am I", &tools, None)
2642 .await
2643 .unwrap();
2644
2645 assert_eq!(result.text, "Checking.");
2646 let ids: Vec<&str> = result.tool_calls.iter().map(|c| c.id.as_str()).collect();
2647 assert_eq!(ids, ["call_1", "call_recovered_1"]);
2648 assert_eq!(result.tool_calls[1].arguments, r#"{"command":"ls"}"#);
2649 let history = backend.history_snapshot().await;
2650 let reply = history.last().unwrap();
2651 assert_eq!(reply["content"], "Checking.");
2652 assert_eq!(reply["tool_calls"].as_array().unwrap().len(), 2);
2653 }
2654
2655 #[tokio::test]
2656 async fn compact_history_rebuilds_system_summary_and_tail() {
2657 let (addr, _requests) = spawn_completion_stub("We refactored backend.rs; tests pass.");
2658 let backend = OpenAiBackend::new(
2659 format!("http://{addr}/v1"),
2660 "test-key",
2661 "test-model",
2662 Some("be helpful".into()),
2663 );
2664 {
2665 let mut history = backend.history.lock().await;
2666 for i in 0..5 {
2667 let role = if i % 2 == 0 { "user" } else { "assistant" };
2668 history.push(serde_json::json!({
2669 "role": role, "content": format!("message {i}"),
2670 }));
2671 }
2672 }
2673
2674 backend.compact_history(2).await.unwrap();
2675
2676 let history = backend.history_snapshot().await;
2677 assert_eq!(history.len(), 4, "system + summary + last 2: {history:?}");
2678 assert_eq!(history[0]["role"], "system");
2679 assert_eq!(history[0]["content"], "be helpful");
2680 assert_eq!(history[1]["role"], "user");
2681 let summary_text = history[1]["content"].as_str().unwrap();
2682 assert!(summary_text.starts_with("[Conversation summary]\n"));
2683 assert!(summary_text.contains("We refactored backend.rs; tests pass."));
2684 assert_eq!(
2685 history[2],
2686 serde_json::json!({ "role": "assistant", "content": "message 3" })
2687 );
2688 assert_eq!(
2689 history[3],
2690 serde_json::json!({ "role": "user", "content": "message 4" })
2691 );
2692 }
2693
2694 /// Compacting a tool-heavy session must not put tool shapes on the wire.
2695 /// The summarization round offers no `tools`, and endpoints reject tool
2696 /// calls and tool results that arrive without a schema — which used to make
2697 /// compaction fail forever in any session that had run a single tool.
2698 #[tokio::test]
2699 async fn compact_history_sends_no_tool_artifacts() {
2700 let (addr, requests) = spawn_completion_stub("Ran git status on main.");
2701 let backend = OpenAiBackend::new(
2702 format!("http://{addr}/v1"),
2703 "test-key",
2704 "test-model",
2705 Some("be helpful".into()),
2706 );
2707 {
2708 let mut history = backend.history.lock().await;
2709 history.push(serde_json::json!({ "role": "user", "content": "check the repo" }));
2710 history.push(serde_json::json!({
2711 "role": "assistant",
2712 "content": null,
2713 "tool_calls": [{
2714 "id": "call_1",
2715 "type": "function",
2716 "function": { "name": "run_command", "arguments": "{\"command\":\"git status\"}" },
2717 }],
2718 }));
2719 history.push(serde_json::json!({
2720 "role": "tool",
2721 "tool_call_id": "call_1",
2722 "content": "on branch main",
2723 }));
2724 }
2725
2726 backend.compact_history(2).await.unwrap();
2727
2728 let body: serde_json::Value =
2729 serde_json::from_str(&requests.recv().unwrap()).expect("request body is JSON");
2730 assert!(
2731 body.get("tools").is_none(),
2732 "summarization offers no tools: {body}"
2733 );
2734 for message in body["messages"].as_array().unwrap() {
2735 assert!(
2736 message.get("tool_calls").is_none(),
2737 "no tool_calls may be sent without a schema: {message}"
2738 );
2739 assert_ne!(
2740 message["role"], "tool",
2741 "no tool results may be sent without a schema: {message}"
2742 );
2743 }
2744
2745 // The tool round still has to survive into the summary request as prose,
2746 // or the summary loses the work the session actually did.
2747 let transcript = body["messages"].as_array().unwrap().last().unwrap()["content"]
2748 .as_str()
2749 .unwrap();
2750 assert!(transcript.contains("called run_command({\"command\":\"git status\"})"));
2751 assert!(transcript.contains("tool result: on branch main"));
2752 }
2753
2754 /// Compaction only fails when even the truncation fallback cannot fit the
2755 /// history in the budget — here the newest message alone is bigger than
2756 /// the whole window.
2757 #[tokio::test]
2758 async fn compact_history_failure_leaves_history_intact() {
2759 // No listener at this address, so summarization fails; the system
2760 // prompt alone is bigger than the window, so even the minimal
2761 // fallback rebuild cannot fit — the genuinely unrecoverable case.
2762 let backend = OpenAiBackend::new(
2763 "http://127.0.0.1:9",
2764 "",
2765 "test-model",
2766 Some("s".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000)),
2767 );
2768 backend
2769 .history
2770 .lock()
2771 .await
2772 .push(serde_json::json!({ "role": "user", "content": "hello" }));
2773 let before = backend.history_snapshot().await;
2774
2775 assert!(backend.compact_history(2).await.is_err());
2776 assert_eq!(backend.history_snapshot().await, before);
2777 }
2778
2779 /// The issue-#125 path: the summarization endpoint is down, and the
2780 /// session must still shrink rather than die with "start a new thread".
2781 #[tokio::test]
2782 async fn failed_summarization_falls_back_to_truncation() {
2783 // Nothing listens here, so the summarization round always fails.
2784 let backend =
2785 OpenAiBackend::new("http://127.0.0.1:9", "", "test-model", Some("sys".into()));
2786 {
2787 let mut history = backend.history.lock().await;
2788 for i in 0..20 {
2789 history.push(serde_json::json!({
2790 "role": "user",
2791 "content": format!("message {i}: {}", "padding ".repeat(200)),
2792 }));
2793 }
2794 }
2795
2796 backend.compact_history(4).await.unwrap();
2797
2798 let history = backend.history_snapshot().await;
2799 assert_eq!(history[0]["role"], "system", "{history:?}");
2800 assert_eq!(history[1]["content"], TRUNCATION_PLACEHOLDER, "{history:?}");
2801 // The newest messages survive; the oldest were dropped to fit.
2802 assert_eq!(
2803 history.last().unwrap()["content"]
2804 .as_str()
2805 .unwrap()
2806 .split(':')
2807 .next()
2808 .unwrap(),
2809 "message 19",
2810 "{history:?}"
2811 );
2812 assert!(
2813 estimate_tokens(&history) <= DEFAULT_CONTEXT_TOKEN_BUDGET,
2814 "fallback result must fit the budget: ~{} tokens",
2815 estimate_tokens(&history)
2816 );
2817 }
2818
2819 /// A history small enough to begin with keeps its whole tail: the
2820 /// fallback drops nothing beyond what `keep_last` already folded away.
2821 #[tokio::test]
2822 async fn truncation_fallback_keeps_the_newest_messages_that_fit() {
2823 let system = vec![serde_json::json!({ "role": "system", "content": "sys" })];
2824 let tail: Vec<serde_json::Value> = (0..4)
2825 .map(|i| serde_json::json!({ "role": "user", "content": format!("m{i}") }))
2826 .collect();
2827
2828 let rebuilt = truncate_history_to_budget(&system, tail).unwrap();
2829
2830 assert_eq!(rebuilt[0]["role"], "system");
2831 assert_eq!(rebuilt[1]["content"], TRUNCATION_PLACEHOLDER);
2832 assert_eq!(rebuilt.len(), 6, "{rebuilt:?}");
2833 assert_eq!(rebuilt[2]["content"], "m0");
2834 assert_eq!(rebuilt[5]["content"], "m3");
2835 }
2836
2837 /// A multi-system-message session keeps every leading system message in
2838 /// the fallback, exactly like the summarization-success path.
2839 #[test]
2840 fn truncation_fallback_keeps_all_leading_system_messages() {
2841 let system = vec![
2842 serde_json::json!({ "role": "system", "content": "seed prompt" }),
2843 serde_json::json!({ "role": "system", "content": "session context" }),
2844 ];
2845 let tail = vec![serde_json::json!({ "role": "user", "content": "hi" })];
2846
2847 let rebuilt = truncate_history_to_budget(&system, tail).unwrap();
2848
2849 assert_eq!(
2850 rebuilt[0]["content"], "seed prompt",
2851 "first system message kept: {rebuilt:?}"
2852 );
2853 assert_eq!(
2854 rebuilt[1]["content"], "session context",
2855 "second system message kept: {rebuilt:?}"
2856 );
2857 }
2858
2859 #[test]
2860 fn truncation_fallback_drops_orphaned_tool_results_with_their_call() {
2861 // A caller that split mid-round hands in a tail starting with tool
2862 // results whose assistant call message already fell outside the kept
2863 // window; those orphans must go before anything else, or strict
2864 // endpoints reject the next request.
2865 let tail = vec![
2866 serde_json::json!({ "role": "tool", "tool_call_id": "call_1", "content": "ok" }),
2867 serde_json::json!({ "role": "user", "content": "thanks" }),
2868 serde_json::json!({ "role": "assistant", "content": "done" }),
2869 ];
2870
2871 let rebuilt = truncate_history_to_budget(&[], tail).unwrap();
2872
2873 let roles: Vec<&str> = rebuilt.iter().filter_map(|m| m["role"].as_str()).collect();
2874 assert_eq!(
2875 roles,
2876 ["user", "user", "assistant"],
2877 "placeholder + the surviving exchange, orphan gone: {rebuilt:?}"
2878 );
2879 }
2880
2881 /// Review follow-up: a tail that drains to empty (one oversized tool
2882 /// round) must still test the minimal rebuild — system + placeholder fits,
2883 /// so the session is recoverable, not "unfixable".
2884 #[test]
2885 fn truncation_fallback_recovers_when_the_whole_tail_was_one_oversized_round() {
2886 let system = vec![serde_json::json!({ "role": "system", "content": "sys" })];
2887 let tail = vec![
2888 serde_json::json!({
2889 "role": "assistant",
2890 "content": null,
2891 "tool_calls": [{
2892 "id": "call_1", "type": "function",
2893 "function": { "name": "read_file", "arguments": "{}" },
2894 }],
2895 }),
2896 serde_json::json!({
2897 "role": "tool",
2898 "tool_call_id": "call_1",
2899 "content": "y".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000),
2900 }),
2901 ];
2902
2903 let rebuilt = truncate_history_to_budget(&system, tail).unwrap();
2904
2905 assert_eq!(rebuilt.len(), 2, "{rebuilt:?}");
2906 assert_eq!(rebuilt[0]["role"], "system");
2907 assert_eq!(rebuilt[1]["content"], TRUNCATION_PLACEHOLDER);
2908 }
2909
2910 #[test]
2911 fn truncation_fallback_reports_when_nothing_fits() {
2912 // Even the minimal rebuild (system + placeholder, no conversation)
2913 // overflows: the system messages alone are bigger than the window.
2914 let system = vec![serde_json::json!({
2915 "role": "system",
2916 "content": "s".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000),
2917 })];
2918
2919 assert!(truncate_history_to_budget(&system, Vec::new()).is_none());
2920 }
2921
2922 /// Review follow-up: the local fallback recovers from a newest message
2923 /// that overflows the window on its own, the same way the remote one does.
2924 #[test]
2925 fn local_truncation_fallback_drops_an_oversized_final_message() {
2926 let system = vec![ChatMessage::system("sys")];
2927 let tail = vec![
2928 ChatMessage::user("hi"),
2929 ChatMessage::user("y".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000)),
2930 ];
2931
2932 let rebuilt = truncate_chat_messages_to_budget(system, tail).unwrap();
2933
2934 assert_eq!(rebuilt.len(), 2, "{rebuilt:?}");
2935 assert_eq!(rebuilt[0].content, "sys");
2936 assert_eq!(rebuilt[1].content, TRUNCATION_PLACEHOLDER);
2937 }
2938
2939 #[test]
2940 fn local_truncation_fallback_keeps_the_newest_messages_that_fit() {
2941 let big = DEFAULT_CONTEXT_TOKEN_BUDGET * 4 / 2;
2942 let tail = vec![
2943 ChatMessage::user("a".repeat(big)),
2944 ChatMessage::user("b".repeat(big)),
2945 ChatMessage::user("newest"),
2946 ];
2947
2948 let rebuilt = truncate_chat_messages_to_budget(Vec::new(), tail).unwrap();
2949
2950 assert_eq!(rebuilt.len(), 3, "oldest dropped, rest kept");
2951 assert_eq!(rebuilt[0].content, TRUNCATION_PLACEHOLDER);
2952 assert!(rebuilt[1].content.starts_with('b'));
2953 assert_eq!(rebuilt[2].content, "newest");
2954 }
2955
2956 #[test]
2957 fn local_truncation_fallback_reports_when_the_system_messages_overflow() {
2958 let system = vec![ChatMessage::system(
2959 "s".repeat(DEFAULT_CONTEXT_TOKEN_BUDGET * 4 + 1000),
2960 )];
2961
2962 assert!(truncate_chat_messages_to_budget(system, Vec::new()).is_none());
2963 }
2964
2965 /// Review follow-up: head and tail share the budget, so the trimmed
2966 /// transcript stays near the cap instead of reaching twice it.
2967 #[test]
2968 fn truncate_transcript_middle_stays_within_the_cap() {
2969 let lines: Vec<String> = (0..2_000)
2970 .map(|i| format!("user: message {i} with some padding text"))
2971 .collect();
2972 let transcript = lines.join("\n");
2973
2974 let trimmed = truncate_transcript_middle(&transcript, 200); // ~800 chars
2975
2976 assert!(trimmed.contains("message 0 "), "opening kept: {trimmed}");
2977 assert!(trimmed.contains("message 1999"), "closing kept: {trimmed}");
2978 assert!(
2979 trimmed.len() <= 800 + 80,
2980 "cap plus the omission marker, got {} chars",
2981 trimmed.len()
2982 );
2983 }
2984
2985 #[test]
2986 fn truncate_transcript_middle_keeps_short_transcripts_verbatim() {
2987 let transcript = "user: hello\n\nassistant: hi";
2988
2989 assert_eq!(truncate_transcript_middle(transcript, 1_000), transcript);
2990 }
2991
2992 /// Review follow-up: a huge final message with no newline in the tail
2993 /// half must not be silently dropped — the closing exchange is what the
2994 /// summary most needs. The head gives up its share instead.
2995 #[test]
2996 fn truncate_transcript_middle_keeps_a_newlineless_final_message() {
2997 let mut lines: Vec<String> = (0..100).map(|i| format!("user: message {i}")).collect();
2998 lines.push(format!(
2999 "user: {}current state: pending review",
3000 "noise ".repeat(600)
3001 ));
3002 let transcript = lines.join("\n");
3003
3004 let trimmed = truncate_transcript_middle(&transcript, 400); // ~1600 chars
3005
3006 assert!(
3007 trimmed.contains("current state: pending review"),
3008 "the closing exchange survives: {trimmed}"
3009 );
3010 assert!(
3011 trimmed.contains("older messages omitted"),
3012 "the gap is marked: {trimmed}"
3013 );
3014 assert!(
3015 !trimmed.contains("message 0"),
3016 "the head gives up its share to the unbreakable tail: {trimmed}"
3017 );
3018 }
3019
3020 /// Review follow-up: no newline anywhere in reach — keep the newest
3021 /// budget, not the oldest (which would invert the contract while the
3022 /// placeholder claims older messages were dropped).
3023 #[test]
3024 fn truncate_transcript_middle_without_any_newline_keeps_the_newest() {
3025 let transcript = format!("old decisions {} current state", "x".repeat(4_000));
3026
3027 let trimmed = truncate_transcript_middle(&transcript, 200); // ~800 chars
3028
3029 assert!(
3030 trimmed.contains("current state"),
3031 "the newest content survives: {trimmed}"
3032 );
3033 assert!(
3034 !trimmed.contains("old decisions"),
3035 "the oldest goes when only one side can survive: {trimmed}"
3036 );
3037 }
3038
3039 #[test]
3040 fn truncate_transcript_middle_cuts_the_middle_not_the_ends() {
3041 let mut lines: Vec<String> = Vec::new();
3042 for i in 0..200 {
3043 lines.push(format!("user: message {i} with some padding text"));
3044 }
3045 let transcript = lines.join("\n");
3046
3047 let trimmed = truncate_transcript_middle(&transcript, 200); // ~800 chars
3048
3049 assert!(trimmed.contains("message 0"), "opening kept: {trimmed}");
3050 assert!(trimmed.contains("message 199"), "closing kept: {trimmed}");
3051 assert!(
3052 trimmed.contains("older messages omitted"),
3053 "gap is marked: {trimmed}"
3054 );
3055 assert!(
3056 trimmed.len() <= transcript.len(),
3057 "never grows the transcript"
3058 );
3059 }
3060
3061 #[test]
3062 fn assistant_message_with_tool_calls_round_trips() {
3063 let message = ResponseMessage {
3064 content: None,
3065 tool_calls: Some(vec![ResponseToolCall {
3066 id: "call_1".to_string(),
3067 function: ResponseFunction {
3068 name: "read_file".to_string(),
3069 arguments: r#"{"path":"a.rs"}"#.to_string(),
3070 },
3071 }]),
3072 };
3073 let value = message.into_history_value();
3074 assert_eq!(value["role"], "assistant");
3075 assert!(value["content"].is_null());
3076 assert_eq!(value["tool_calls"][0]["id"], "call_1");
3077 assert_eq!(value["tool_calls"][0]["type"], "function");
3078 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
3079 }
3080 }