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