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, 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 }
62
63 /// Backend errors are plain strings. Callers map them to ACP errors.
64 pub type BackendError = String;
65
66 /// Rough context budget for a conversation, in estimated tokens (see
67 /// [`estimate_tokens`]). When a snapshot exceeds this, the agent loops compact
68 /// history before the next tool round.
69 pub const DEFAULT_CONTEXT_TOKEN_BUDGET: usize = 24_000;
70
71 /// How many trailing messages survive a compaction verbatim (the rest are
72 /// folded into the summary).
73 pub const COMPACT_KEEP_LAST: usize = 6;
74
75 /// The summarization request sent to the model when compacting history.
76 const SUMMARIZE_PROMPT: &str = "Summarize this coding session so far: decisions made, \
77 files touched, current state, open items. Be concise and factual.";
78
79 /// Render a history snapshot as a plain-text transcript, with tool calls and
80 /// tool results spelled out as prose rather than left in their wire shapes.
81 ///
82 /// Compaction summarizes a conversation that is, by definition, thick with
83 /// `tool_calls` and `role: "tool"` messages — but the summarization round asks
84 /// for a plain answer and so sends no `tools` array. Forwarding the raw shapes
85 /// in that request produces tool blocks with no schema to validate against,
86 /// which strict endpoints reject outright: Anthropic answers 400, so every
87 /// compaction of a session that had ever run a tool failed, permanently, no
88 /// matter how small the history was. Flattening to text keeps everything the
89 /// summary actually needs and drops the shapes that only make sense alongside
90 /// a tool schema. It also sidesteps orphaned `tool_call_id`s and role-
91 /// alternation rules, neither of which a transcript can violate.
92 fn transcript_for_summary(history: &[serde_json::Value]) -> String {
93 let mut lines: Vec<String> = Vec::new();
94 for message in history {
95 let role = message["role"].as_str().unwrap_or("user");
96 // The system prompt is carried over verbatim, so it needn't be summarized.
97 if role == "system" {
98 continue;
99 }
100
101 let mut parts: Vec<String> = Vec::new();
102 if let Some(text) = message["content"].as_str()
103 && !text.trim().is_empty()
104 {
105 parts.push(text.to_string());
106 }
107 for call in message["tool_calls"].as_array().into_iter().flatten() {
108 parts.push(format!(
109 "called {}({})",
110 call["function"]["name"].as_str().unwrap_or("tool"),
111 call["function"]["arguments"].as_str().unwrap_or_default(),
112 ));
113 }
114 if parts.is_empty() {
115 continue;
116 }
117
118 let label = if role == "tool" { "tool result" } else { role };
119 lines.push(format!("{label}: {}", parts.join("\n")));
120 }
121 lines.join("\n\n")
122 }
123
124 /// Crude token estimate for a history snapshot: serialized characters / 4.
125 /// Deliberately model-agnostic — it only needs to be in the right ballpark to
126 /// decide when compaction is worth an extra inference round.
127 pub fn estimate_tokens(history: &[serde_json::Value]) -> usize {
128 let chars: usize = history
129 .iter()
130 .map(|message| message.to_string().chars().count())
131 .sum();
132 chars / 4
133 }
134
135 /// A sink for streaming assistant text deltas to the UI as they are produced.
136 ///
137 /// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
138 /// text fragment through it as the model emits it; the returned [`TurnResult`]
139 /// still carries the fully assembled text (and any tool calls). When the sink is
140 /// `None`, the backend runs in non-streaming mode. Unbounded so the inference
141 /// task never blocks on a slow consumer.
142 pub type TokenSink = tokio::sync::mpsc::UnboundedSender<String>;
143
144 // ── The trait ───────────────────────────────────────────────────────────────────
145
146 /// A swappable inference backend driving siGit Code's agent loop.
147 #[async_trait]
148 pub trait InferenceBackend: Send + Sync {
149 /// Start an assistant turn from a new user message, offering `tools`.
150 ///
151 /// If `sink` is `Some`, text is streamed through it as it is generated. A
152 /// backend may decline to stream a given round (for example, on-device
153 /// inference cannot stream while it is still deciding whether to call a
154 /// tool); in that case the text is delivered only via the returned result.
155 async fn send_message_with_tools(
156 &self,
157 text: &str,
158 tools: &[ToolSpec],
159 sink: Option<&TokenSink>,
160 ) -> Result<TurnResult, BackendError>;
161
162 /// Continue the turn by returning tool results. `allow_tool_calls` controls
163 /// whether `tools` are offered again; the complete catalog remains available
164 /// so a disabled round can recognize and suppress tool-shaped model output.
165 /// `sink` streams assistant text when set.
166 async fn send_tool_results(
167 &self,
168 results: Vec<ToolResult>,
169 tools: &[ToolSpec],
170 allow_tool_calls: bool,
171 sink: Option<&TokenSink>,
172 ) -> Result<TurnResult, BackendError>;
173
174 /// Record tool results in the conversation history *without* asking the
175 /// model to continue the turn. Used when a turn is abandoned mid-round
176 /// (the user cancelled at the permission gate): by then the assistant
177 /// message carrying the tool calls is already in history, and leaving them
178 /// unanswered makes strict OpenAI-compatible endpoints reject every later
179 /// request in the session.
180 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
181
182 /// Whether inference runs over the network (a configured provider) rather
183 /// than on-device. Drives UI labelling so the displayed model can't claim a
184 /// local model while requests actually go to the cloud.
185 fn is_remote(&self) -> bool;
186
187 /// A serializable snapshot of the conversation history, one JSON object per
188 /// message (`{"role": ..., "content": ...}` at minimum). The snapshot is
189 /// what the session store persists; it includes any seeded system message
190 /// so [`InferenceBackend::restore_history`] can replace state wholesale.
191 async fn history_snapshot(&self) -> Vec<serde_json::Value>;
192
193 /// Replace the conversation history with a previously saved snapshot.
194 /// Backends that cannot represent every entry (e.g. on-device history has
195 /// no tool-call structure) flatten what they can and drop the rest.
196 async fn restore_history(&self, history: Vec<serde_json::Value>);
197
198 /// Shrink the conversation history: summarize everything so far with one
199 /// extra (non-streaming) inference round, then rebuild history as
200 /// `[system message, summary, last keep_last non-system messages]`. On
201 /// error the original history is left in place.
202 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError>;
203 }
204
205 // ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
206
207 /// On-device inference. A thin adapter over `onde::ChatEngine`.
208 pub struct LocalBackend {
209 engine: Arc<ChatEngine>,
210 }
211
212 impl LocalBackend {
213 pub fn new(engine: Arc<ChatEngine>) -> Self {
214 Self { engine }
215 }
216 }
217
218 fn to_onde_tools(tools: &[ToolSpec]) -> Vec<ToolDefinition> {
219 tools
220 .iter()
221 .map(|tool| ToolDefinition {
222 name: tool.name.clone(),
223 description: tool.description.clone(),
224 parameters_schema: tool.parameters_schema.clone(),
225 })
226 .collect()
227 }
228
229 #[async_trait]
230 impl InferenceBackend for LocalBackend {
231 async fn send_message_with_tools(
232 &self,
233 text: &str,
234 tools: &[ToolSpec],
235 sink: Option<&TokenSink>,
236 ) -> Result<TurnResult, BackendError> {
237 // onde's tool-aware path is non-streaming: it has to buffer the whole
238 // reply to detect tool calls. We can only stream when no tools are on
239 // offer (a plain answer), which is exactly the tools-disabled case.
240 if let Some(sink) = sink
241 && tools.is_empty()
242 {
243 let rx = self
244 .engine
245 .stream_message(text)
246 .await
247 .map_err(|error| error.to_string())?;
248 return drain_onde_stream(rx, sink).await;
249 }
250
251 let onde_tools = to_onde_tools(tools);
252 let result = self
253 .engine
254 .send_message_with_tools(text, &onde_tools)
255 .await
256 .map_err(|error| error.to_string())?;
257 Ok(onde_result_to_turn(result))
258 }
259
260 async fn send_tool_results(
261 &self,
262 results: Vec<ToolResult>,
263 tools: &[ToolSpec],
264 allow_tool_calls: bool,
265 sink: Option<&TokenSink>,
266 ) -> Result<TurnResult, BackendError> {
267 let onde_results: Vec<onde::inference::ToolResult> = results
268 .into_iter()
269 .map(|result| onde::inference::ToolResult {
270 tool_call_id: result.tool_call_id,
271 content: result.content,
272 })
273 .collect();
274
275 // A forced-text round is the only round onde can stream, since no
276 // further tool calls are parsed.
277 if let Some(sink) = sink
278 && !allow_tool_calls
279 {
280 let rx = self
281 .engine
282 .stream_tool_results(onde_results, None)
283 .await
284 .map_err(|error| error.to_string())?;
285 return drain_onde_stream(rx, sink).await;
286 }
287
288 let onde_tools = allow_tool_calls.then(|| to_onde_tools(tools));
289 let result = self
290 .engine
291 .send_tool_results(onde_results, onde_tools.as_deref())
292 .await
293 .map_err(|error| error.to_string())?;
294 Ok(onde_result_to_turn(result))
295 }
296
297 async fn record_cancelled_tool_results(&self, _results: Vec<ToolResult>) {
298 // onde's public API cannot append tool-result history entries without
299 // running another inference round, so the dangling tool call stays in
300 // its history. The chat template replays it as-is, which local models
301 // tolerate — worst case the model re-issues the call next turn.
302 }
303
304 fn is_remote(&self) -> bool {
305 false
306 }
307
308 async fn history_snapshot(&self) -> Vec<serde_json::Value> {
309 // onde's `history()` already flattens tool entries: assistant tool
310 // calls become plain assistant text and tool results are omitted, so
311 // the snapshot is lossy for tool-heavy turns (acceptable in this MVP).
312 self.engine
313 .history()
314 .await
315 .iter()
316 .map(|message| {
317 serde_json::json!({
318 "role": message.role.to_string(),
319 "content": message.content,
320 })
321 })
322 .collect()
323 }
324
325 async fn restore_history(&self, history: Vec<serde_json::Value>) {
326 self.engine.clear_history().await;
327 for entry in history {
328 let role = entry["role"].as_str().unwrap_or("");
329 let content = entry["content"].as_str().unwrap_or("").to_string();
330 // Tool-call-only assistant entries and empty tool results carry no
331 // text a plain chat history can replay; drop them.
332 if content.is_empty() && role != "user" && role != "system" {
333 continue;
334 }
335 let message = match role {
336 "system" => ChatMessage::system(content),
337 "user" => ChatMessage::user(content),
338 "assistant" => ChatMessage::assistant(content),
339 // Tool results flatten to plain text (MVP; acceptable loss).
340 "tool" => ChatMessage::user(format!("[tool result]\n{content}")),
341 _ => continue,
342 };
343 self.engine.push_history(message).await;
344 }
345 }
346
347 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
348 let snapshot = self.engine.history().await;
349 // One plain (tool-free) inference round produces the summary. On error
350 // history is untouched — send_message only mutates it on success, and
351 // whatever it appended is wiped by the clear below anyway.
352 let result = self
353 .engine
354 .send_message(SUMMARIZE_PROMPT)
355 .await
356 .map_err(|error| error.to_string())?;
357 // Local models may reason in <think> blocks; keep only the visible part.
358 let (_think, summary) = crate::chat::strip_think_blocks(&result.text);
359
360 self.engine.clear_history().await;
361 // Leading system messages carry the session context; keep them all.
362 for message in snapshot
363 .iter()
364 .take_while(|message| message.role == ChatRole::System)
365 {
366 self.engine.push_history(message.clone()).await;
367 }
368 self.engine
369 .push_history(ChatMessage::user(format!(
370 "[Conversation summary]\n{summary}"
371 )))
372 .await;
373 let non_system: Vec<&ChatMessage> = snapshot
374 .iter()
375 .filter(|message| message.role != ChatRole::System)
376 .collect();
377 let tail_start = non_system.len().saturating_sub(keep_last);
378 for message in &non_system[tail_start..] {
379 self.engine.push_history((*message).clone()).await;
380 }
381 Ok(())
382 }
383 }
384
385 /// Drain an onde streaming receiver, forwarding each token to `sink` and
386 /// assembling the full text. onde reports stream failures as a final chunk whose
387 /// `finish_reason` is `"error: …"`; surface those as a backend error.
388 async fn drain_onde_stream(
389 mut rx: tokio::sync::mpsc::Receiver<onde::inference::StreamChunk>,
390 sink: &TokenSink,
391 ) -> Result<TurnResult, BackendError> {
392 let mut text = String::new();
393 while let Some(chunk) = rx.recv().await {
394 if !chunk.delta.is_empty() {
395 text.push_str(&chunk.delta);
396 // The receiver is the UI; if it's gone the turn is being cancelled,
397 // so stop assembling rather than spinning the model to completion.
398 if sink.send(chunk.delta).is_err() {
399 break;
400 }
401 }
402 if chunk.done {
403 if let Some(reason) = chunk.finish_reason
404 && let Some(message) = reason.strip_prefix("error: ")
405 {
406 return Err(message.to_string());
407 }
408 break;
409 }
410 }
411 Ok(TurnResult {
412 text,
413 tool_calls: Vec::new(),
414 })
415 }
416
417 /// Convert an `onde` tool-aware result into the neutral [`TurnResult`].
418 fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
419 TurnResult {
420 text: result.text,
421 tool_calls: result
422 .tool_calls
423 .into_iter()
424 .map(|call| ToolCall {
425 id: call.id,
426 name: call.function_name,
427 arguments: call.arguments,
428 })
429 .collect(),
430 }
431 }
432
433 // ── OpenAI-compatible backend ─────────────────────────────────────────────────────
434
435 /// Inference against any OpenAI-compatible Chat Completions endpoint.
436 ///
437 /// Conversation state is held client-side and replayed on every request, so the
438 /// endpoint can be stateless. Standard OpenAI function-calling is used end to
439 /// end (`tools`, `choices[].message.tool_calls`, `role: "tool"` follow-ups).
440 pub struct OpenAiBackend {
441 base_url: String,
442 api_key: String,
443 model: String,
444 http: reqwest::Client,
445 /// The full message list sent on each request (system + turns + tool results).
446 history: Mutex<Vec<serde_json::Value>>,
447 }
448
449 impl OpenAiBackend {
450 /// Build a backend for `{base_url, api_key, model}`, seeding the optional
451 /// system prompt. `base_url` should include the API root (e.g. ending in
452 /// `/v1`); the chat path is appended.
453 pub fn new(
454 base_url: impl Into<String>,
455 api_key: impl Into<String>,
456 model: impl Into<String>,
457 system_prompt: Option<String>,
458 ) -> Self {
459 let mut history = Vec::new();
460 if let Some(prompt) = system_prompt {
461 history.push(serde_json::json!({ "role": "system", "content": prompt }));
462 }
463 Self {
464 base_url: base_url.into(),
465 api_key: api_key.into(),
466 model: model.into(),
467 http: reqwest::Client::new(),
468 history: Mutex::new(history),
469 }
470 }
471
472 fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
473 tools
474 .iter()
475 .map(|tool| {
476 // parameters_schema is a JSON string; parse it, defaulting to an
477 // empty object schema if malformed.
478 let parameters: serde_json::Value = serde_json::from_str(&tool.parameters_schema)
479 .unwrap_or_else(|_| serde_json::json!({ "type": "object", "properties": {} }));
480 serde_json::json!({
481 "type": "function",
482 "function": {
483 "name": tool.name,
484 "description": tool.description,
485 "parameters": parameters,
486 }
487 })
488 })
489 .collect()
490 }
491
492 /// POST the current history and apply the assistant reply to history.
493 /// `tools` is always the known catalog, while `allow_tool_calls` determines
494 /// whether it is advertised to the model and whether returned calls may run.
495 /// Streams via SSE when `sink` is set; otherwise reads a single JSON response.
496 async fn complete(
497 &self,
498 tools: &[ToolSpec],
499 allow_tool_calls: bool,
500 sink: Option<&TokenSink>,
501 ) -> Result<TurnResult, BackendError> {
502 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
503 let streaming = sink.is_some();
504
505 let mut body = serde_json::json!({
506 "model": self.model,
507 "messages": *self.history.lock().await,
508 "stream": streaming,
509 });
510 if allow_tool_calls && !tools.is_empty() {
511 body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
512 // OpenAI specifies `auto` as the default when tools are present,
513 // but not every OpenAI-compatible gateway implements that default.
514 // Sending it explicitly is important for the cloud path: otherwise
515 // a model can describe the action it intends to take as text and
516 // end the turn without returning a tool call at all.
517 body["tool_choice"] = serde_json::Value::String("auto".to_string());
518 }
519
520 let response = self
521 .http
522 .post(&url)
523 .bearer_auth(&self.api_key)
524 .json(&body)
525 .send()
526 .await
527 .map_err(|error| format!("request to {url} failed: {error}"))?;
528
529 if !response.status().is_success() {
530 let status = response.status();
531 let body = response.text().await.unwrap_or_default();
532 return Err(describe_api_error(status, &body));
533 }
534
535 if let Some(sink) = sink {
536 self.consume_stream(response, sink, tools, allow_tool_calls)
537 .await
538 } else {
539 self.consume_json(response, tools, allow_tool_calls).await
540 }
541 }
542
543 /// Parse a single non-streaming chat-completion response.
544 async fn consume_json(
545 &self,
546 response: reqwest::Response,
547 tools: &[ToolSpec],
548 allow_tool_calls: bool,
549 ) -> Result<TurnResult, BackendError> {
550 let parsed: ChatCompletion = response
551 .json()
552 .await
553 .map_err(|error| format!("response parse error: {error}"))?;
554
555 let message = parsed
556 .choices
557 .into_iter()
558 .next()
559 .map(|choice| choice.message)
560 .ok_or_else(|| "endpoint returned no choices".to_string())?;
561
562 let mut text = message.content.clone().unwrap_or_default();
563 let tool_calls: Vec<ToolCall> = message
564 .tool_calls
565 .iter()
566 .flatten()
567 .map(|call| ToolCall {
568 id: call.id.clone(),
569 name: call.function.name.clone(),
570 arguments: call.function.arguments.clone(),
571 })
572 .collect();
573
574 if !allow_tool_calls {
575 let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
576 text = cleaned;
577 let suppressed = tool_calls.len() + recovered.len();
578 if suppressed > 0 {
579 let names = tool_calls
580 .iter()
581 .map(|call| call.name.as_str())
582 .chain(recovered.iter().map(|call| call.name.as_str()))
583 .collect::<Vec<_>>()
584 .join(", ");
585 log::warn!(
586 "suppressed {suppressed} tool call(s) from a forced-text response: {names}"
587 );
588 }
589 self.history
590 .lock()
591 .await
592 .push(streamed_assistant_history(&text, &[]));
593 return Ok(TurnResult {
594 text,
595 tool_calls: Vec::new(),
596 });
597 }
598
599 // Some models write a tool call out as literal `<tool_call>` text
600 // instead of using the structured field (see `inline_tool_calls`).
601 // Recover it, or the turn ends with the tag rendered as prose and
602 // whatever the model meant to do is dropped.
603 if tool_calls.is_empty() {
604 let (cleaned, recovered) = crate::inline_tool_calls::extract(&text, tools);
605 if !recovered.is_empty() {
606 log::warn!(
607 "recovered {} tool call(s) the model emitted as text instead of a structured call",
608 recovered.len()
609 );
610 let tool_calls: Vec<ToolCall> = recovered
611 .into_iter()
612 .enumerate()
613 .map(|(index, call)| ToolCall {
614 id: format!("call_recovered_{index}"),
615 name: call.name,
616 arguments: call.arguments,
617 })
618 .collect();
619 // Record the recovered shape, not the raw tag: the tool
620 // results that follow have to answer an assistant message
621 // that actually carries these calls, or the next request is
622 // rejected for orphaned tool results.
623 self.history
624 .lock()
625 .await
626 .push(streamed_assistant_history(&cleaned, &tool_calls));
627 return Ok(TurnResult {
628 text: cleaned,
629 tool_calls,
630 });
631 }
632 }
633
634 // Record the assistant turn so later tool results have context.
635 self.history.lock().await.push(message.into_history_value());
636
637 Ok(TurnResult { text, tool_calls })
638 }
639
640 /// Consume an OpenAI Server-Sent Events stream, forwarding content deltas to
641 /// `sink` and reassembling any tool calls (which arrive fragmented across
642 /// chunks, keyed by `index`).
643 async fn consume_stream(
644 &self,
645 response: reqwest::Response,
646 sink: &TokenSink,
647 tools: &[ToolSpec],
648 allow_tool_calls: bool,
649 ) -> Result<TurnResult, BackendError> {
650 use futures::StreamExt;
651
652 let mut stream = response.bytes_stream();
653 // Newlines are ASCII, so splitting raw bytes on `\n` never bisects a
654 // multibyte UTF-8 sequence; we only lossily decode whole lines.
655 let mut buffer: Vec<u8> = Vec::new();
656 let mut text = String::new();
657 let mut tool_accum: Vec<StreamingToolCall> = Vec::new();
658 let mut done = false;
659 // Recovers a tool call the model wrote as literal `<tool_call>` text
660 // instead of a structured delta. Scanning here (rather than after the
661 // stream) keeps the tag off the UI: content goes straight to `sink` as
662 // it arrives, so by the time a whole turn is assembled the tag has
663 // already been rendered. See `inline_tool_calls`.
664 let mut scanner = crate::inline_tool_calls::StreamScanner::new(tools);
665 let mut recovered: Vec<ToolCall> = Vec::new();
666
667 while let Some(item) = stream.next().await {
668 let bytes = item.map_err(|error| format!("stream read error: {error}"))?;
669 buffer.extend_from_slice(&bytes);
670
671 while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
672 let line: Vec<u8> = buffer.drain(..=pos).collect();
673 let line = String::from_utf8_lossy(&line);
674 let line = line.trim();
675
676 let Some(data) = line.strip_prefix("data:") else {
677 continue;
678 };
679 let data = data.trim();
680 if data == "[DONE]" {
681 done = true;
682 break;
683 }
684 if data.is_empty() {
685 continue;
686 }
687
688 // An endpoint that fails after the status line is already sent
689 // reports it in the stream instead, as an ordinary `data:`
690 // frame holding an error envelope. siGit Code Cloud does this
691 // when the upstream fails mid-turn. It has no `choices`, so
692 // without this it parses as an empty chunk and is skipped, and
693 // the turn ends looking like the model simply said nothing.
694 if let Some(message) = api_error_message(data) {
695 return Err(message);
696 }
697
698 let chunk: StreamCompletion = match serde_json::from_str(data) {
699 Ok(chunk) => chunk,
700 // Skip keep-alive comments and anything we can't parse rather
701 // than aborting a turn over one malformed frame.
702 Err(_) => continue,
703 };
704
705 let Some(choice) = chunk.choices.into_iter().next() else {
706 continue;
707 };
708 if let Some(content) = choice.delta.content
709 && !content.is_empty()
710 {
711 let mut cancelled = false;
712 for event in scanner.push(&content) {
713 match event {
714 crate::inline_tool_calls::ScanEvent::Text(chunk) => {
715 text.push_str(&chunk);
716 if sink.send(chunk).is_err() {
717 // Consumer dropped (turn cancelled).
718 cancelled = true;
719 break;
720 }
721 }
722 crate::inline_tool_calls::ScanEvent::ToolCall(call) => {
723 if allow_tool_calls {
724 log::warn!(
725 "recovered tool call '{}' the model emitted as text instead of a structured call",
726 call.name
727 );
728 }
729 recovered.push(ToolCall {
730 id: format!("call_recovered_{}", recovered.len()),
731 name: call.name,
732 arguments: call.arguments,
733 });
734 }
735 }
736 }
737 if cancelled {
738 done = true;
739 break;
740 }
741 }
742 for delta in choice.delta.tool_calls.into_iter().flatten() {
743 let index = delta.index.unwrap_or(0) as usize;
744 if tool_accum.len() <= index {
745 tool_accum.resize_with(index + 1, StreamingToolCall::default);
746 }
747 let slot = &mut tool_accum[index];
748 if let Some(id) = delta.id {
749 slot.id = id;
750 }
751 if let Some(function) = delta.function {
752 if let Some(name) = function.name {
753 slot.name = name;
754 }
755 if let Some(arguments) = function.arguments {
756 slot.arguments.push_str(&arguments);
757 }
758 }
759 }
760 }
761
762 if done {
763 break;
764 }
765 }
766
767 // Text held back waiting on a tag that never closed is just text.
768 if let Some(leftover) = scanner.take_pending() {
769 text.push_str(&leftover);
770 let _ = sink.send(leftover);
771 }
772
773 let mut tool_calls: Vec<ToolCall> = tool_accum
774 .iter()
775 .filter(|call| !call.name.is_empty())
776 .enumerate()
777 .map(|(index, call)| ToolCall {
778 id: if call.id.is_empty() {
779 format!("call_{index}")
780 } else {
781 call.id.clone()
782 },
783 name: call.name.clone(),
784 arguments: call.arguments.clone(),
785 })
786 .collect();
787 tool_calls.extend(recovered);
788
789 if !allow_tool_calls && !tool_calls.is_empty() {
790 let names = tool_calls
791 .iter()
792 .map(|call| call.name.as_str())
793 .collect::<Vec<_>>()
794 .join(", ");
795 log::warn!(
796 "suppressed {} tool call(s) from a forced-text response: {names}",
797 tool_calls.len()
798 );
799 tool_calls.clear();
800 }
801
802 // Record the assistant turn so later tool results have context.
803 self.history
804 .lock()
805 .await
806 .push(streamed_assistant_history(&text, &tool_calls));
807
808 Ok(TurnResult { text, tool_calls })
809 }
810 }
811
812 /// One tool call being reassembled from streamed deltas.
813 #[derive(Default)]
814 struct StreamingToolCall {
815 id: String,
816 name: String,
817 arguments: String,
818 }
819
820 /// Rebuild the assistant message for replay in history after a streamed turn,
821 /// preserving any tool calls so the follow-up request is well-formed. Mirrors
822 /// [`ResponseMessage::into_history_value`] for the non-streaming path.
823 fn streamed_assistant_history(text: &str, tool_calls: &[ToolCall]) -> serde_json::Value {
824 let mut message = serde_json::json!({ "role": "assistant" });
825 message["content"] = if text.is_empty() {
826 serde_json::Value::Null
827 } else {
828 serde_json::Value::String(text.to_string())
829 };
830 if !tool_calls.is_empty() {
831 message["tool_calls"] = serde_json::json!(
832 tool_calls
833 .iter()
834 .map(|call| serde_json::json!({
835 "id": call.id,
836 "type": "function",
837 "function": {
838 "name": call.name,
839 "arguments": call.arguments,
840 }
841 }))
842 .collect::<Vec<_>>()
843 );
844 }
845 message
846 }
847
848 #[async_trait]
849 impl InferenceBackend for OpenAiBackend {
850 async fn send_message_with_tools(
851 &self,
852 text: &str,
853 tools: &[ToolSpec],
854 sink: Option<&TokenSink>,
855 ) -> Result<TurnResult, BackendError> {
856 self.history
857 .lock()
858 .await
859 .push(serde_json::json!({ "role": "user", "content": text }));
860 self.complete(tools, true, sink).await
861 }
862
863 async fn send_tool_results(
864 &self,
865 results: Vec<ToolResult>,
866 tools: &[ToolSpec],
867 allow_tool_calls: bool,
868 sink: Option<&TokenSink>,
869 ) -> Result<TurnResult, BackendError> {
870 {
871 let mut history = self.history.lock().await;
872 for result in results {
873 history.push(serde_json::json!({
874 "role": "tool",
875 "tool_call_id": result.tool_call_id,
876 "content": result.content,
877 }));
878 }
879 }
880 self.complete(tools, allow_tool_calls, sink).await
881 }
882
883 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
884 let mut history = self.history.lock().await;
885 for result in results {
886 history.push(serde_json::json!({
887 "role": "tool",
888 "tool_call_id": result.tool_call_id,
889 "content": result.content,
890 }));
891 }
892 }
893
894 fn is_remote(&self) -> bool {
895 true
896 }
897
898 async fn history_snapshot(&self) -> Vec<serde_json::Value> {
899 self.history.lock().await.clone()
900 }
901
902 async fn restore_history(&self, history: Vec<serde_json::Value>) {
903 // The snapshot includes the seeded system message, so a wholesale
904 // replacement restores exactly what was saved.
905 *self.history.lock().await = history;
906 }
907
908 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
909 let snapshot: Vec<serde_json::Value> = self.history.lock().await.clone();
910
911 let system = snapshot
912 .first()
913 .filter(|message| message["role"] == "system")
914 .cloned();
915
916 // Ask the endpoint for a summary of the conversation so far, through the
917 // ordinary completion machinery (non-streaming). The request carries the
918 // conversation as a flattened transcript in a single user message rather
919 // than the live history: this round offers no tools, and a tool-shaped
920 // history sent without a tool schema is rejected upstream (see
921 // `transcript_for_summary`).
922 let mut request = Vec::new();
923 if let Some(system) = system.clone() {
924 request.push(system);
925 }
926 request.push(serde_json::json!({
927 "role": "user",
928 "content": format!(
929 "{}\n\n{SUMMARIZE_PROMPT}",
930 transcript_for_summary(&snapshot),
931 ),
932 }));
933 *self.history.lock().await = request;
934
935 let summary = match self.complete(&[], false, None).await {
936 Ok(result) => result.text,
937 Err(error) => {
938 // Roll back the summarization request; the turn never happened.
939 *self.history.lock().await = snapshot;
940 return Err(error);
941 }
942 };
943
944 let non_system: Vec<serde_json::Value> = snapshot
945 .iter()
946 .filter(|message| message["role"] != "system")
947 .cloned()
948 .collect();
949 let tail_start = non_system.len().saturating_sub(keep_last);
950 let mut tail = non_system[tail_start..].to_vec();
951 // Drop leading tool results whose assistant tool-call message was
952 // summarized away — strict endpoints reject orphaned `role: "tool"`
953 // entries on the very next request.
954 while tail
955 .first()
956 .is_some_and(|message| message["role"] == "tool")
957 {
958 tail.remove(0);
959 }
960
961 let mut rebuilt = Vec::new();
962 if let Some(system) = system {
963 rebuilt.push(system);
964 }
965 rebuilt.push(serde_json::json!({
966 "role": "user",
967 "content": format!("[Conversation summary]\n{summary}"),
968 }));
969 rebuilt.extend(tail);
970 *self.history.lock().await = rebuilt;
971 Ok(())
972 }
973 }
974
975 // ── OpenAI error shape ────────────────────────────────────────────────────────
976
977 /// The OpenAI error envelope: `{"error": {"message": ..., "type": ...}}`.
978 ///
979 /// Every endpoint siGit talks to speaks it, including siGit Code Cloud, whose
980 /// messages are written for the person reading them ("Monthly siGit Code Cloud
981 /// allowance reached..."). Worth unwrapping rather than pasting the raw body
982 /// into the editor's error banner.
983 #[derive(Debug, Deserialize)]
984 struct ApiErrorEnvelope {
985 error: ApiErrorBody,
986 }
987
988 #[derive(Debug, Deserialize)]
989 struct ApiErrorBody {
990 #[serde(default)]
991 message: Option<String>,
992 }
993
994 /// How much of an unparseable error body to keep. An endpoint behind a proxy
995 /// can answer with a full HTML page, and the whole thing ends up in the
996 /// editor's error banner.
997 const ERROR_BODY_LIMIT: usize = 500;
998
999 /// Turn an error response into something worth showing a person.
1000 ///
1001 /// The endpoint's own message wins when there is one: it is written for the
1002 /// user, and the status code repeats what it already says. Anything else falls
1003 /// back to the status plus whatever the body held, which is all there is to go
1004 /// on.
1005 fn describe_api_error(status: reqwest::StatusCode, body: &str) -> String {
1006 if let Some(message) = api_error_message(body) {
1007 return message;
1008 }
1009
1010 let body = body.trim();
1011 if body.is_empty() {
1012 return format!("endpoint returned {status}");
1013 }
1014
1015 let mut detail = body;
1016 if detail.len() > ERROR_BODY_LIMIT {
1017 let mut cut = ERROR_BODY_LIMIT;
1018 while !detail.is_char_boundary(cut) {
1019 cut -= 1;
1020 }
1021 detail = &detail[..cut];
1022 return format!("endpoint returned {status}: {detail}…");
1023 }
1024 format!("endpoint returned {status}: {detail}")
1025 }
1026
1027 /// The human-readable message out of an OpenAI error envelope, if the body is
1028 /// one and carries a non-empty message.
1029 fn api_error_message(body: &str) -> Option<String> {
1030 let envelope: ApiErrorEnvelope = serde_json::from_str(body).ok()?;
1031 let message = envelope.error.message?;
1032 let message = message.trim();
1033 (!message.is_empty()).then(|| message.to_string())
1034 }
1035
1036 // ── OpenAI response shapes ────────────────────────────────────────────────────────
1037
1038 #[derive(Debug, Deserialize)]
1039 struct ChatCompletion {
1040 #[serde(default)]
1041 choices: Vec<CompletionChoice>,
1042 }
1043
1044 #[derive(Debug, Deserialize)]
1045 struct CompletionChoice {
1046 message: ResponseMessage,
1047 }
1048
1049 #[derive(Debug, Deserialize)]
1050 struct ResponseMessage {
1051 #[serde(default)]
1052 content: Option<String>,
1053 #[serde(default)]
1054 tool_calls: Option<Vec<ResponseToolCall>>,
1055 }
1056
1057 impl ResponseMessage {
1058 /// Reconstruct the assistant message for replay in history, preserving any
1059 /// tool calls so the follow-up request is well-formed.
1060 fn into_history_value(self) -> serde_json::Value {
1061 let mut message = serde_json::json!({ "role": "assistant" });
1062 message["content"] = match self.content {
1063 Some(text) => serde_json::Value::String(text),
1064 None => serde_json::Value::Null,
1065 };
1066 if let Some(tool_calls) = self.tool_calls {
1067 message["tool_calls"] = serde_json::json!(
1068 tool_calls
1069 .into_iter()
1070 .map(|call| serde_json::json!({
1071 "id": call.id,
1072 "type": "function",
1073 "function": {
1074 "name": call.function.name,
1075 "arguments": call.function.arguments,
1076 }
1077 }))
1078 .collect::<Vec<_>>()
1079 );
1080 }
1081 message
1082 }
1083 }
1084
1085 #[derive(Debug, Deserialize)]
1086 struct ResponseToolCall {
1087 id: String,
1088 function: ResponseFunction,
1089 }
1090
1091 #[derive(Debug, Deserialize)]
1092 struct ResponseFunction {
1093 name: String,
1094 #[serde(default)]
1095 arguments: String,
1096 }
1097
1098 // ── OpenAI streaming (SSE) chunk shapes ─────────────────────────────────────────
1099
1100 #[derive(Debug, Deserialize)]
1101 struct StreamCompletion {
1102 #[serde(default)]
1103 choices: Vec<StreamChoice>,
1104 }
1105
1106 #[derive(Debug, Deserialize)]
1107 struct StreamChoice {
1108 #[serde(default)]
1109 delta: StreamDelta,
1110 }
1111
1112 #[derive(Debug, Default, Deserialize)]
1113 struct StreamDelta {
1114 #[serde(default)]
1115 content: Option<String>,
1116 #[serde(default)]
1117 tool_calls: Option<Vec<StreamToolCallDelta>>,
1118 }
1119
1120 #[derive(Debug, Deserialize)]
1121 struct StreamToolCallDelta {
1122 #[serde(default)]
1123 index: Option<u32>,
1124 #[serde(default)]
1125 id: Option<String>,
1126 #[serde(default)]
1127 function: Option<StreamFunctionDelta>,
1128 }
1129
1130 #[derive(Debug, Deserialize)]
1131 struct StreamFunctionDelta {
1132 #[serde(default)]
1133 name: Option<String>,
1134 #[serde(default)]
1135 arguments: Option<String>,
1136 }
1137
1138 // ── Carrying a conversation across a model switch ────────────────────────────
1139
1140 /// The part of a history snapshot that survives a model switch: every
1141 /// non-system message, with half-finished tool plumbing repaired.
1142 ///
1143 /// System messages are dropped because the backend being switched *to* seeds
1144 /// its own — a different model's prompt, or a freshly pushed session-context
1145 /// message. A switch can land mid-turn, after the assistant asked for a tool
1146 /// but before the results came back; a tool call with no matching result (or a
1147 /// result with no matching call) makes strict OpenAI-compatible endpoints
1148 /// reject every later request in the session, so those halves are stripped
1149 /// instead of carried.
1150 pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
1151 let answered: std::collections::HashSet<String> = snapshot
1152 .iter()
1153 .filter(|message| message["role"] == "tool")
1154 .filter_map(|message| message["tool_call_id"].as_str().map(str::to_string))
1155 .collect();
1156
1157 let mut carried: Vec<serde_json::Value> = Vec::with_capacity(snapshot.len());
1158 let mut called: std::collections::HashSet<String> = std::collections::HashSet::new();
1159
1160 for mut message in snapshot {
1161 match message["role"].as_str().unwrap_or_default() {
1162 "system" => continue,
1163 "assistant" => {
1164 if let Some(calls) = message["tool_calls"].as_array() {
1165 let kept: Vec<serde_json::Value> = calls
1166 .iter()
1167 .filter(|call| call["id"].as_str().is_some_and(|id| answered.contains(id)))
1168 .cloned()
1169 .collect();
1170 if kept.is_empty() {
1171 // Only unanswered requests: keep whatever text came
1172 // with them, drop the message if there was none.
1173 if let Some(object) = message.as_object_mut() {
1174 object.remove("tool_calls");
1175 }
1176 if message["content"].as_str().unwrap_or_default().is_empty() {
1177 continue;
1178 }
1179 } else {
1180 for call in &kept {
1181 called.insert(call["id"].as_str().unwrap_or_default().to_string());
1182 }
1183 message["tool_calls"] = serde_json::Value::Array(kept);
1184 }
1185 }
1186 carried.push(message);
1187 }
1188 "tool" => {
1189 let answers_a_kept_call = message["tool_call_id"]
1190 .as_str()
1191 .is_some_and(|id| called.contains(id));
1192 if answers_a_kept_call {
1193 carried.push(message);
1194 }
1195 }
1196 _ => carried.push(message),
1197 }
1198 }
1199
1200 carried
1201 }
1202
1203 /// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
1204 /// system messages `backend` seeded for itself. Used when a model switch
1205 /// installs a new backend — or reloads the on-device engine, which wipes its
1206 /// history — so the thread continues under the new model instead of restarting.
1207 pub async fn adopt_carryover(backend: &dyn InferenceBackend, carried: Vec<serde_json::Value>) {
1208 if carried.is_empty() {
1209 return;
1210 }
1211 let mut rebuilt: Vec<serde_json::Value> = backend
1212 .history_snapshot()
1213 .await
1214 .into_iter()
1215 .take_while(|message| message["role"] == "system")
1216 .collect();
1217 rebuilt.extend(carried);
1218 backend.restore_history(rebuilt).await;
1219 }
1220
1221 #[cfg(test)]
1222 mod tests {
1223 use super::*;
1224
1225 /// The case from the issue: the endpoint says something a person can act
1226 /// on, and the status code and JSON around it are noise.
1227 #[test]
1228 fn an_endpoints_own_message_is_what_the_user_sees() {
1229 let body = r#"{"error":{"message":"Monthly siGit Code Cloud allowance reached. It resets at the start of your next billing period.","type":"server_error"}}"#;
1230
1231 let described = describe_api_error(reqwest::StatusCode::TOO_MANY_REQUESTS, body);
1232
1233 assert_eq!(
1234 described,
1235 "Monthly siGit Code Cloud allowance reached. \
1236 It resets at the start of your next billing period."
1237 );
1238 }
1239
1240 #[test]
1241 fn a_body_that_is_not_an_error_envelope_keeps_the_status() {
1242 let described =
1243 describe_api_error(reqwest::StatusCode::BAD_GATEWAY, "<html>bad gateway</html>");
1244
1245 assert!(described.contains("502"), "{described}");
1246 assert!(described.contains("bad gateway"), "{described}");
1247 }
1248
1249 #[test]
1250 fn an_empty_body_still_says_something() {
1251 let described = describe_api_error(reqwest::StatusCode::INTERNAL_SERVER_ERROR, " ");
1252
1253 assert_eq!(described, "endpoint returned 500 Internal Server Error");
1254 }
1255
1256 #[test]
1257 fn an_oversized_body_is_cut_down() {
1258 let body = "x".repeat(ERROR_BODY_LIMIT * 3);
1259
1260 let described = describe_api_error(reqwest::StatusCode::BAD_GATEWAY, &body);
1261
1262 assert!(described.len() < ERROR_BODY_LIMIT + 100, "{described}");
1263 assert!(described.ends_with('…'), "{described}");
1264 }
1265
1266 /// An envelope with nothing useful in it must not shadow the status, which
1267 /// would leave the user with an empty error.
1268 #[test]
1269 fn an_envelope_with_a_blank_message_falls_back() {
1270 let described = describe_api_error(
1271 reqwest::StatusCode::FORBIDDEN,
1272 r#"{"error":{"message":" "}}"#,
1273 );
1274
1275 assert!(described.contains("403"), "{described}");
1276 }
1277
1278 #[test]
1279 fn tools_json_wraps_function_schema() {
1280 let tools = vec![ToolSpec {
1281 name: "read_file".to_string(),
1282 description: "Read a file".to_string(),
1283 parameters_schema: r#"{"type":"object","properties":{"path":{"type":"string"}}}"#
1284 .to_string(),
1285 }];
1286 let json = OpenAiBackend::tools_json(&tools);
1287 assert_eq!(json[0]["type"], "function");
1288 assert_eq!(json[0]["function"]["name"], "read_file");
1289 assert_eq!(
1290 json[0]["function"]["parameters"]["properties"]["path"]["type"],
1291 "string"
1292 );
1293 }
1294
1295 #[test]
1296 fn malformed_schema_falls_back_to_empty_object() {
1297 let tools = vec![ToolSpec {
1298 name: "x".to_string(),
1299 description: String::new(),
1300 parameters_schema: "not json".to_string(),
1301 }];
1302 let json = OpenAiBackend::tools_json(&tools);
1303 assert_eq!(json[0]["function"]["parameters"]["type"], "object");
1304 }
1305
1306 #[test]
1307 fn streamed_assistant_history_omits_empty_tool_calls() {
1308 let value = streamed_assistant_history("hello", &[]);
1309 assert_eq!(value["role"], "assistant");
1310 assert_eq!(value["content"], "hello");
1311 assert!(value.get("tool_calls").is_none());
1312 }
1313
1314 #[test]
1315 fn streamed_assistant_history_preserves_tool_calls() {
1316 let calls = vec![ToolCall {
1317 id: "call_0".to_string(),
1318 name: "read_file".to_string(),
1319 arguments: r#"{"path":"a.rs"}"#.to_string(),
1320 }];
1321 let value = streamed_assistant_history("", &calls);
1322 assert!(value["content"].is_null());
1323 assert_eq!(value["tool_calls"][0]["id"], "call_0");
1324 assert_eq!(value["tool_calls"][0]["type"], "function");
1325 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
1326 assert_eq!(
1327 value["tool_calls"][0]["function"]["arguments"],
1328 r#"{"path":"a.rs"}"#
1329 );
1330 }
1331
1332 #[tokio::test]
1333 async fn cancelled_tool_results_close_out_history() {
1334 let backend = OpenAiBackend::new("http://localhost", "", "test-model", None);
1335 backend
1336 .history
1337 .lock()
1338 .await
1339 .push(streamed_assistant_history(
1340 "",
1341 &[ToolCall {
1342 id: "call_9".to_string(),
1343 name: "run_command".to_string(),
1344 arguments: r#"{"command":"ls"}"#.to_string(),
1345 }],
1346 ));
1347
1348 backend
1349 .record_cancelled_tool_results(vec![ToolResult {
1350 tool_call_id: "call_9".to_string(),
1351 content: "cancelled by the user".to_string(),
1352 }])
1353 .await;
1354
1355 let history = backend.history.lock().await;
1356 let last = history.last().unwrap();
1357 assert_eq!(last["role"], "tool");
1358 assert_eq!(last["tool_call_id"], "call_9");
1359 assert_eq!(last["content"], "cancelled by the user");
1360 }
1361
1362 #[test]
1363 fn carryover_drops_system_messages_and_keeps_the_turns() {
1364 let snapshot = vec![
1365 serde_json::json!({ "role": "system", "content": "old prompt" }),
1366 serde_json::json!({ "role": "user", "content": "hello" }),
1367 serde_json::json!({ "role": "assistant", "content": "hi" }),
1368 ];
1369
1370 let carried = carryover_history(snapshot);
1371
1372 assert_eq!(carried.len(), 2);
1373 assert_eq!(carried[0]["role"], "user");
1374 assert_eq!(carried[1]["content"], "hi");
1375 }
1376
1377 #[test]
1378 fn carryover_strips_a_tool_call_that_never_got_a_result() {
1379 // Switching mid-turn: the assistant asked for a tool, nothing answered.
1380 let snapshot = vec![
1381 serde_json::json!({ "role": "user", "content": "read a.rs" }),
1382 streamed_assistant_history(
1383 "on it",
1384 &[ToolCall {
1385 id: "call_1".to_string(),
1386 name: "read_file".to_string(),
1387 arguments: "{}".to_string(),
1388 }],
1389 ),
1390 ];
1391
1392 let carried = carryover_history(snapshot);
1393
1394 assert_eq!(carried.len(), 2);
1395 assert_eq!(carried[1]["content"], "on it");
1396 assert!(
1397 carried[1].get("tool_calls").is_none(),
1398 "an unanswered tool call must not survive the switch"
1399 );
1400 }
1401
1402 #[test]
1403 fn carryover_drops_a_textless_unanswered_tool_call_and_its_late_result() {
1404 let snapshot = vec![
1405 streamed_assistant_history(
1406 "",
1407 &[ToolCall {
1408 id: "call_1".to_string(),
1409 name: "read_file".to_string(),
1410 arguments: "{}".to_string(),
1411 }],
1412 ),
1413 // An orphan: its assistant message is gone with the line above.
1414 serde_json::json!({
1415 "role": "tool",
1416 "tool_call_id": "call_other",
1417 "content": "file contents",
1418 }),
1419 ];
1420
1421 assert!(carryover_history(snapshot).is_empty());
1422 }
1423
1424 #[test]
1425 fn carryover_keeps_an_answered_tool_call_paired_with_its_result() {
1426 let snapshot = vec![
1427 streamed_assistant_history(
1428 "",
1429 &[ToolCall {
1430 id: "call_1".to_string(),
1431 name: "read_file".to_string(),
1432 arguments: "{}".to_string(),
1433 }],
1434 ),
1435 serde_json::json!({
1436 "role": "tool",
1437 "tool_call_id": "call_1",
1438 "content": "file contents",
1439 }),
1440 ];
1441
1442 let carried = carryover_history(snapshot);
1443
1444 assert_eq!(carried.len(), 2);
1445 assert_eq!(carried[0]["tool_calls"][0]["id"], "call_1");
1446 assert_eq!(carried[1]["tool_call_id"], "call_1");
1447 }
1448
1449 #[tokio::test]
1450 async fn adopt_carryover_replays_the_thread_under_the_new_system_prompt() {
1451 let new_backend =
1452 OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
1453
1454 let carried = carryover_history(vec![
1455 serde_json::json!({ "role": "system", "content": "old prompt" }),
1456 serde_json::json!({ "role": "user", "content": "hello" }),
1457 serde_json::json!({ "role": "assistant", "content": "hi" }),
1458 ]);
1459 adopt_carryover(&new_backend, carried).await;
1460
1461 let history = new_backend.history_snapshot().await;
1462 assert_eq!(history.len(), 3);
1463 assert_eq!(history[0]["role"], "system");
1464 assert_eq!(history[0]["content"], "new prompt");
1465 assert_eq!(history[1]["content"], "hello");
1466 assert_eq!(history[2]["content"], "hi");
1467 }
1468
1469 #[tokio::test]
1470 async fn adopt_carryover_leaves_a_fresh_backend_alone() {
1471 let new_backend =
1472 OpenAiBackend::new("http://localhost", "", "m", Some("new prompt".into()));
1473
1474 adopt_carryover(&new_backend, Vec::new()).await;
1475
1476 let history = new_backend.history_snapshot().await;
1477 assert_eq!(history.len(), 1);
1478 assert_eq!(history[0]["content"], "new prompt");
1479 }
1480
1481 #[test]
1482 fn estimate_tokens_scales_with_serialized_size() {
1483 assert_eq!(estimate_tokens(&[]), 0);
1484
1485 let short = vec![serde_json::json!({ "role": "user", "content": "hi" })];
1486 let long = vec![serde_json::json!({ "role": "user", "content": "x".repeat(4_000) })];
1487 let short_estimate = estimate_tokens(&short);
1488 let long_estimate = estimate_tokens(&long);
1489
1490 assert!(short_estimate > 0, "non-empty history estimates > 0 tokens");
1491 assert!(long_estimate > short_estimate, "longer history costs more");
1492 // 4,000 content chars / 4 ≈ 1,000 tokens, plus a little JSON framing.
1493 assert!((1_000..1_100).contains(&long_estimate), "{long_estimate}");
1494 }
1495
1496 #[tokio::test]
1497 async fn openai_snapshot_restore_round_trips_exactly() {
1498 let backend = OpenAiBackend::new("http://localhost", "", "m", Some("be helpful".into()));
1499 {
1500 let mut history = backend.history.lock().await;
1501 history.push(serde_json::json!({ "role": "user", "content": "hello" }));
1502 history.push(streamed_assistant_history(
1503 "",
1504 &[ToolCall {
1505 id: "call_1".to_string(),
1506 name: "read_file".to_string(),
1507 arguments: r#"{"path":"a.rs"}"#.to_string(),
1508 }],
1509 ));
1510 history.push(serde_json::json!({
1511 "role": "tool", "tool_call_id": "call_1", "content": "fn main() {}",
1512 }));
1513 history.push(serde_json::json!({ "role": "assistant", "content": "done" }));
1514 }
1515 let snapshot = backend.history_snapshot().await;
1516 assert_eq!(
1517 snapshot[0]["role"], "system",
1518 "snapshot keeps the system message"
1519 );
1520
1521 // Restoring into a backend seeded with a *different* system prompt must
1522 // replace everything, including that seed.
1523 let restored = OpenAiBackend::new("http://localhost", "", "m", Some("other seed".into()));
1524 restored.restore_history(snapshot.clone()).await;
1525 assert_eq!(restored.history_snapshot().await, snapshot);
1526 }
1527
1528 /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
1529 /// a std listener and answers with a fixed non-streaming completion. The
1530 /// receiver yields the request body the backend actually put on the wire.
1531 fn spawn_completion_stub(
1532 summary: &str,
1533 ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
1534 use std::io::{Read, Write};
1535
1536 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1537 let addr = listener.local_addr().unwrap();
1538 let (sender, receiver) = std::sync::mpsc::channel();
1539 let body = serde_json::json!({
1540 "choices": [{ "message": { "role": "assistant", "content": summary } }]
1541 })
1542 .to_string();
1543 std::thread::spawn(move || {
1544 let (mut stream, _) = listener.accept().unwrap();
1545 // Read until the full request (headers + content-length body) is in.
1546 let mut request = Vec::new();
1547 let mut chunk = [0u8; 4096];
1548 loop {
1549 let n = stream.read(&mut chunk).unwrap_or(0);
1550 if n == 0 {
1551 break;
1552 }
1553 request.extend_from_slice(&chunk[..n]);
1554 if let Some(headers_end) =
1555 request.windows(4).position(|window| window == b"\r\n\r\n")
1556 {
1557 let headers = String::from_utf8_lossy(&request[..headers_end]);
1558 let content_length = headers
1559 .lines()
1560 .find_map(|line| {
1561 line.to_ascii_lowercase()
1562 .strip_prefix("content-length:")
1563 .map(|value| value.trim().parse::<usize>().unwrap_or(0))
1564 })
1565 .unwrap_or(0);
1566 if request.len() >= headers_end + 4 + content_length {
1567 let _ = sender.send(
1568 String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
1569 );
1570 break;
1571 }
1572 }
1573 }
1574 let response = format!(
1575 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
1576 content-length: {}\r\nconnection: close\r\n\r\n{}",
1577 body.len(),
1578 body
1579 );
1580 let _ = stream.write_all(response.as_bytes());
1581 });
1582 (addr, receiver)
1583 }
1584
1585 #[tokio::test]
1586 async fn compact_history_rebuilds_system_summary_and_tail() {
1587 let (addr, _requests) = spawn_completion_stub("We refactored backend.rs; tests pass.");
1588 let backend = OpenAiBackend::new(
1589 format!("http://{addr}/v1"),
1590 "test-key",
1591 "test-model",
1592 Some("be helpful".into()),
1593 );
1594 {
1595 let mut history = backend.history.lock().await;
1596 for i in 0..5 {
1597 let role = if i % 2 == 0 { "user" } else { "assistant" };
1598 history.push(serde_json::json!({
1599 "role": role, "content": format!("message {i}"),
1600 }));
1601 }
1602 }
1603
1604 backend.compact_history(2).await.unwrap();
1605
1606 let history = backend.history_snapshot().await;
1607 assert_eq!(history.len(), 4, "system + summary + last 2: {history:?}");
1608 assert_eq!(history[0]["role"], "system");
1609 assert_eq!(history[0]["content"], "be helpful");
1610 assert_eq!(history[1]["role"], "user");
1611 let summary_text = history[1]["content"].as_str().unwrap();
1612 assert!(summary_text.starts_with("[Conversation summary]\n"));
1613 assert!(summary_text.contains("We refactored backend.rs; tests pass."));
1614 assert_eq!(
1615 history[2],
1616 serde_json::json!({ "role": "assistant", "content": "message 3" })
1617 );
1618 assert_eq!(
1619 history[3],
1620 serde_json::json!({ "role": "user", "content": "message 4" })
1621 );
1622 }
1623
1624 /// Compacting a tool-heavy session must not put tool shapes on the wire.
1625 /// The summarization round offers no `tools`, and endpoints reject tool
1626 /// calls and tool results that arrive without a schema — which used to make
1627 /// compaction fail forever in any session that had run a single tool.
1628 #[tokio::test]
1629 async fn compact_history_sends_no_tool_artifacts() {
1630 let (addr, requests) = spawn_completion_stub("Ran git status on main.");
1631 let backend = OpenAiBackend::new(
1632 format!("http://{addr}/v1"),
1633 "test-key",
1634 "test-model",
1635 Some("be helpful".into()),
1636 );
1637 {
1638 let mut history = backend.history.lock().await;
1639 history.push(serde_json::json!({ "role": "user", "content": "check the repo" }));
1640 history.push(serde_json::json!({
1641 "role": "assistant",
1642 "content": null,
1643 "tool_calls": [{
1644 "id": "call_1",
1645 "type": "function",
1646 "function": { "name": "run_command", "arguments": "{\"command\":\"git status\"}" },
1647 }],
1648 }));
1649 history.push(serde_json::json!({
1650 "role": "tool",
1651 "tool_call_id": "call_1",
1652 "content": "on branch main",
1653 }));
1654 }
1655
1656 backend.compact_history(2).await.unwrap();
1657
1658 let body: serde_json::Value =
1659 serde_json::from_str(&requests.recv().unwrap()).expect("request body is JSON");
1660 assert!(
1661 body.get("tools").is_none(),
1662 "summarization offers no tools: {body}"
1663 );
1664 for message in body["messages"].as_array().unwrap() {
1665 assert!(
1666 message.get("tool_calls").is_none(),
1667 "no tool_calls may be sent without a schema: {message}"
1668 );
1669 assert_ne!(
1670 message["role"], "tool",
1671 "no tool results may be sent without a schema: {message}"
1672 );
1673 }
1674
1675 // The tool round still has to survive into the summary request as prose,
1676 // or the summary loses the work the session actually did.
1677 let transcript = body["messages"].as_array().unwrap().last().unwrap()["content"]
1678 .as_str()
1679 .unwrap();
1680 assert!(transcript.contains("called run_command({\"command\":\"git status\"})"));
1681 assert!(transcript.contains("tool result: on branch main"));
1682 }
1683
1684 #[tokio::test]
1685 async fn compact_history_failure_leaves_history_intact() {
1686 // No listener at this address: the summarization request fails, and
1687 // history must roll back to exactly what it was.
1688 let backend =
1689 OpenAiBackend::new("http://127.0.0.1:9", "", "test-model", Some("sys".into()));
1690 backend
1691 .history
1692 .lock()
1693 .await
1694 .push(serde_json::json!({ "role": "user", "content": "hello" }));
1695 let before = backend.history_snapshot().await;
1696
1697 assert!(backend.compact_history(2).await.is_err());
1698 assert_eq!(backend.history_snapshot().await, before);
1699 }
1700
1701 #[test]
1702 fn assistant_message_with_tool_calls_round_trips() {
1703 let message = ResponseMessage {
1704 content: None,
1705 tool_calls: Some(vec![ResponseToolCall {
1706 id: "call_1".to_string(),
1707 function: ResponseFunction {
1708 name: "read_file".to_string(),
1709 arguments: r#"{"path":"a.rs"}"#.to_string(),
1710 },
1711 }]),
1712 };
1713 let value = message.into_history_value();
1714 assert_eq!(value["role"], "assistant");
1715 assert!(value["content"].is_null());
1716 assert_eq!(value["tool_calls"][0]["id"], "call_1");
1717 assert_eq!(value["tool_calls"][0]["type"], "function");
1718 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
1719 }
1720 }