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