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