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, 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 /// A sink for streaming assistant text deltas to the UI as they are produced.
66 ///
67 /// When a caller passes `Some(sink)`, a streaming-capable backend forwards each
68 /// text fragment through it as the model emits it; the returned [`TurnResult`]
69 /// still carries the fully assembled text (and any tool calls). When the sink is
70 /// `None`, the backend runs in non-streaming mode. Unbounded so the inference
71 /// task never blocks on a slow consumer.
72 pub type TokenSink = tokio::sync::mpsc::UnboundedSender<String>;
73
74 // ── The trait ───────────────────────────────────────────────────────────────────
75
76 /// A swappable inference backend driving siGit Code's agent loop.
77 #[async_trait]
78 pub trait InferenceBackend: Send + Sync {
79 /// Start an assistant turn from a new user message, offering `tools`.
80 ///
81 /// If `sink` is `Some`, text is streamed through it as it is generated. A
82 /// backend may decline to stream a given round (for example, on-device
83 /// inference cannot stream while it is still deciding whether to call a
84 /// tool); in that case the text is delivered only via the returned result.
85 async fn send_message_with_tools(
86 &self,
87 text: &str,
88 tools: &[ToolSpec],
89 sink: Option<&TokenSink>,
90 ) -> Result<TurnResult, BackendError>;
91
92 /// Continue the turn by returning tool results. `tools` may be `None` on the
93 /// final round to force a text answer. `sink` streams that text when set.
94 async fn send_tool_results(
95 &self,
96 results: Vec<ToolResult>,
97 tools: Option<&[ToolSpec]>,
98 sink: Option<&TokenSink>,
99 ) -> Result<TurnResult, BackendError>;
100
101 /// Record tool results in the conversation history *without* asking the
102 /// model to continue the turn. Used when a turn is abandoned mid-round
103 /// (the user cancelled at the permission gate): by then the assistant
104 /// message carrying the tool calls is already in history, and leaving them
105 /// unanswered makes strict OpenAI-compatible endpoints reject every later
106 /// request in the session.
107 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>);
108
109 /// Whether inference runs over the network (a configured provider) rather
110 /// than on-device. Drives UI labelling so the displayed model can't claim a
111 /// local model while requests actually go to the cloud.
112 fn is_remote(&self) -> bool;
113 }
114
115 // ── Local backend (onde ChatEngine) ──────────────────────────────────────────────
116
117 /// On-device inference. A thin adapter over `onde::ChatEngine`.
118 pub struct LocalBackend {
119 engine: Arc<ChatEngine>,
120 }
121
122 impl LocalBackend {
123 pub fn new(engine: Arc<ChatEngine>) -> Self {
124 Self { engine }
125 }
126 }
127
128 fn to_onde_tools(tools: &[ToolSpec]) -> Vec<ToolDefinition> {
129 tools
130 .iter()
131 .map(|tool| ToolDefinition {
132 name: tool.name.clone(),
133 description: tool.description.clone(),
134 parameters_schema: tool.parameters_schema.clone(),
135 })
136 .collect()
137 }
138
139 #[async_trait]
140 impl InferenceBackend for LocalBackend {
141 async fn send_message_with_tools(
142 &self,
143 text: &str,
144 tools: &[ToolSpec],
145 sink: Option<&TokenSink>,
146 ) -> Result<TurnResult, BackendError> {
147 // onde's tool-aware path is non-streaming: it has to buffer the whole
148 // reply to detect tool calls. We can only stream when no tools are on
149 // offer (a plain answer), which is exactly the tools-disabled case.
150 if let Some(sink) = sink
151 && tools.is_empty()
152 {
153 let rx = self
154 .engine
155 .stream_message(text)
156 .await
157 .map_err(|error| error.to_string())?;
158 return drain_onde_stream(rx, sink).await;
159 }
160
161 let onde_tools = to_onde_tools(tools);
162 let result = self
163 .engine
164 .send_message_with_tools(text, &onde_tools)
165 .await
166 .map_err(|error| error.to_string())?;
167 Ok(onde_result_to_turn(result))
168 }
169
170 async fn send_tool_results(
171 &self,
172 results: Vec<ToolResult>,
173 tools: Option<&[ToolSpec]>,
174 sink: Option<&TokenSink>,
175 ) -> Result<TurnResult, BackendError> {
176 let onde_results: Vec<onde::inference::ToolResult> = results
177 .into_iter()
178 .map(|result| onde::inference::ToolResult {
179 tool_call_id: result.tool_call_id,
180 content: result.content,
181 })
182 .collect();
183
184 // The final round passes `tools = None` to force a text answer; that's
185 // the only round onde can stream, since no further tool calls are parsed.
186 if let Some(sink) = sink
187 && tools.is_none()
188 {
189 let rx = self
190 .engine
191 .stream_tool_results(onde_results, None)
192 .await
193 .map_err(|error| error.to_string())?;
194 return drain_onde_stream(rx, sink).await;
195 }
196
197 let onde_tools = tools.map(to_onde_tools);
198 let result = self
199 .engine
200 .send_tool_results(onde_results, onde_tools.as_deref())
201 .await
202 .map_err(|error| error.to_string())?;
203 Ok(onde_result_to_turn(result))
204 }
205
206 async fn record_cancelled_tool_results(&self, _results: Vec<ToolResult>) {
207 // onde's public API cannot append tool-result history entries without
208 // running another inference round, so the dangling tool call stays in
209 // its history. The chat template replays it as-is, which local models
210 // tolerate — worst case the model re-issues the call next turn.
211 }
212
213 fn is_remote(&self) -> bool {
214 false
215 }
216 }
217
218 /// Drain an onde streaming receiver, forwarding each token to `sink` and
219 /// assembling the full text. onde reports stream failures as a final chunk whose
220 /// `finish_reason` is `"error: …"`; surface those as a backend error.
221 async fn drain_onde_stream(
222 mut rx: tokio::sync::mpsc::Receiver<onde::inference::StreamChunk>,
223 sink: &TokenSink,
224 ) -> Result<TurnResult, BackendError> {
225 let mut text = String::new();
226 while let Some(chunk) = rx.recv().await {
227 if !chunk.delta.is_empty() {
228 text.push_str(&chunk.delta);
229 // The receiver is the UI; if it's gone the turn is being cancelled,
230 // so stop assembling rather than spinning the model to completion.
231 if sink.send(chunk.delta).is_err() {
232 break;
233 }
234 }
235 if chunk.done {
236 if let Some(reason) = chunk.finish_reason
237 && let Some(message) = reason.strip_prefix("error: ")
238 {
239 return Err(message.to_string());
240 }
241 break;
242 }
243 }
244 Ok(TurnResult {
245 text,
246 tool_calls: Vec::new(),
247 })
248 }
249
250 /// Convert an `onde` tool-aware result into the neutral [`TurnResult`].
251 fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
252 TurnResult {
253 text: result.text,
254 tool_calls: result
255 .tool_calls
256 .into_iter()
257 .map(|call| ToolCall {
258 id: call.id,
259 name: call.function_name,
260 arguments: call.arguments,
261 })
262 .collect(),
263 }
264 }
265
266 // ── OpenAI-compatible backend ─────────────────────────────────────────────────────
267
268 /// Inference against any OpenAI-compatible Chat Completions endpoint.
269 ///
270 /// Conversation state is held client-side and replayed on every request, so the
271 /// endpoint can be stateless. Standard OpenAI function-calling is used end to
272 /// end (`tools`, `choices[].message.tool_calls`, `role: "tool"` follow-ups).
273 pub struct OpenAiBackend {
274 base_url: String,
275 api_key: String,
276 model: String,
277 http: reqwest::Client,
278 /// The full message list sent on each request (system + turns + tool results).
279 history: Mutex<Vec<serde_json::Value>>,
280 }
281
282 impl OpenAiBackend {
283 /// Build a backend for `{base_url, api_key, model}`, seeding the optional
284 /// system prompt. `base_url` should include the API root (e.g. ending in
285 /// `/v1`); the chat path is appended.
286 pub fn new(
287 base_url: impl Into<String>,
288 api_key: impl Into<String>,
289 model: impl Into<String>,
290 system_prompt: Option<String>,
291 ) -> Self {
292 let mut history = Vec::new();
293 if let Some(prompt) = system_prompt {
294 history.push(serde_json::json!({ "role": "system", "content": prompt }));
295 }
296 Self {
297 base_url: base_url.into(),
298 api_key: api_key.into(),
299 model: model.into(),
300 http: reqwest::Client::new(),
301 history: Mutex::new(history),
302 }
303 }
304
305 fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
306 tools
307 .iter()
308 .map(|tool| {
309 // parameters_schema is a JSON string; parse it, defaulting to an
310 // empty object schema if malformed.
311 let parameters: serde_json::Value = serde_json::from_str(&tool.parameters_schema)
312 .unwrap_or_else(|_| serde_json::json!({ "type": "object", "properties": {} }));
313 serde_json::json!({
314 "type": "function",
315 "function": {
316 "name": tool.name,
317 "description": tool.description,
318 "parameters": parameters,
319 }
320 })
321 })
322 .collect()
323 }
324
325 /// POST the current history (plus `tools`) and apply the assistant reply to
326 /// history, returning the neutral turn result. Streams via SSE when `sink`
327 /// is set; otherwise reads a single JSON response.
328 async fn complete(
329 &self,
330 tools: Option<&[ToolSpec]>,
331 sink: Option<&TokenSink>,
332 ) -> Result<TurnResult, BackendError> {
333 let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
334 let streaming = sink.is_some();
335
336 let mut body = serde_json::json!({
337 "model": self.model,
338 "messages": *self.history.lock().await,
339 "stream": streaming,
340 });
341 if let Some(tools) = tools
342 && !tools.is_empty()
343 {
344 body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
345 }
346
347 let response = self
348 .http
349 .post(&url)
350 .bearer_auth(&self.api_key)
351 .json(&body)
352 .send()
353 .await
354 .map_err(|error| format!("request to {url} failed: {error}"))?;
355
356 if !response.status().is_success() {
357 let status = response.status();
358 let detail = response.text().await.unwrap_or_default();
359 return Err(format!("endpoint returned {status}: {detail}"));
360 }
361
362 if let Some(sink) = sink {
363 self.consume_stream(response, sink).await
364 } else {
365 self.consume_json(response).await
366 }
367 }
368
369 /// Parse a single non-streaming chat-completion response.
370 async fn consume_json(&self, response: reqwest::Response) -> Result<TurnResult, BackendError> {
371 let parsed: ChatCompletion = response
372 .json()
373 .await
374 .map_err(|error| format!("response parse error: {error}"))?;
375
376 let message = parsed
377 .choices
378 .into_iter()
379 .next()
380 .map(|choice| choice.message)
381 .ok_or_else(|| "endpoint returned no choices".to_string())?;
382
383 let text = message.content.clone().unwrap_or_default();
384 let tool_calls: Vec<ToolCall> = message
385 .tool_calls
386 .iter()
387 .flatten()
388 .map(|call| ToolCall {
389 id: call.id.clone(),
390 name: call.function.name.clone(),
391 arguments: call.function.arguments.clone(),
392 })
393 .collect();
394
395 // Record the assistant turn so later tool results have context.
396 self.history.lock().await.push(message.into_history_value());
397
398 Ok(TurnResult { text, tool_calls })
399 }
400
401 /// Consume an OpenAI Server-Sent Events stream, forwarding content deltas to
402 /// `sink` and reassembling any tool calls (which arrive fragmented across
403 /// chunks, keyed by `index`).
404 async fn consume_stream(
405 &self,
406 response: reqwest::Response,
407 sink: &TokenSink,
408 ) -> Result<TurnResult, BackendError> {
409 use futures::StreamExt;
410
411 let mut stream = response.bytes_stream();
412 // Newlines are ASCII, so splitting raw bytes on `\n` never bisects a
413 // multibyte UTF-8 sequence; we only lossily decode whole lines.
414 let mut buffer: Vec<u8> = Vec::new();
415 let mut text = String::new();
416 let mut tool_accum: Vec<StreamingToolCall> = Vec::new();
417 let mut done = false;
418
419 while let Some(item) = stream.next().await {
420 let bytes = item.map_err(|error| format!("stream read error: {error}"))?;
421 buffer.extend_from_slice(&bytes);
422
423 while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
424 let line: Vec<u8> = buffer.drain(..=pos).collect();
425 let line = String::from_utf8_lossy(&line);
426 let line = line.trim();
427
428 let Some(data) = line.strip_prefix("data:") else {
429 continue;
430 };
431 let data = data.trim();
432 if data == "[DONE]" {
433 done = true;
434 break;
435 }
436 if data.is_empty() {
437 continue;
438 }
439
440 let chunk: StreamCompletion = match serde_json::from_str(data) {
441 Ok(chunk) => chunk,
442 // Skip keep-alive comments and anything we can't parse rather
443 // than aborting a turn over one malformed frame.
444 Err(_) => continue,
445 };
446
447 let Some(choice) = chunk.choices.into_iter().next() else {
448 continue;
449 };
450 if let Some(content) = choice.delta.content
451 && !content.is_empty()
452 {
453 text.push_str(&content);
454 if sink.send(content).is_err() {
455 // Consumer dropped (turn cancelled) — stop reading.
456 done = true;
457 break;
458 }
459 }
460 for delta in choice.delta.tool_calls.into_iter().flatten() {
461 let index = delta.index.unwrap_or(0) as usize;
462 if tool_accum.len() <= index {
463 tool_accum.resize_with(index + 1, StreamingToolCall::default);
464 }
465 let slot = &mut tool_accum[index];
466 if let Some(id) = delta.id {
467 slot.id = id;
468 }
469 if let Some(function) = delta.function {
470 if let Some(name) = function.name {
471 slot.name = name;
472 }
473 if let Some(arguments) = function.arguments {
474 slot.arguments.push_str(&arguments);
475 }
476 }
477 }
478 }
479
480 if done {
481 break;
482 }
483 }
484
485 let tool_calls: Vec<ToolCall> = tool_accum
486 .iter()
487 .filter(|call| !call.name.is_empty())
488 .enumerate()
489 .map(|(index, call)| ToolCall {
490 id: if call.id.is_empty() {
491 format!("call_{index}")
492 } else {
493 call.id.clone()
494 },
495 name: call.name.clone(),
496 arguments: call.arguments.clone(),
497 })
498 .collect();
499
500 // Record the assistant turn so later tool results have context.
501 self.history
502 .lock()
503 .await
504 .push(streamed_assistant_history(&text, &tool_calls));
505
506 Ok(TurnResult { text, tool_calls })
507 }
508 }
509
510 /// One tool call being reassembled from streamed deltas.
511 #[derive(Default)]
512 struct StreamingToolCall {
513 id: String,
514 name: String,
515 arguments: String,
516 }
517
518 /// Rebuild the assistant message for replay in history after a streamed turn,
519 /// preserving any tool calls so the follow-up request is well-formed. Mirrors
520 /// [`ResponseMessage::into_history_value`] for the non-streaming path.
521 fn streamed_assistant_history(text: &str, tool_calls: &[ToolCall]) -> serde_json::Value {
522 let mut message = serde_json::json!({ "role": "assistant" });
523 message["content"] = if text.is_empty() {
524 serde_json::Value::Null
525 } else {
526 serde_json::Value::String(text.to_string())
527 };
528 if !tool_calls.is_empty() {
529 message["tool_calls"] = serde_json::json!(
530 tool_calls
531 .iter()
532 .map(|call| serde_json::json!({
533 "id": call.id,
534 "type": "function",
535 "function": {
536 "name": call.name,
537 "arguments": call.arguments,
538 }
539 }))
540 .collect::<Vec<_>>()
541 );
542 }
543 message
544 }
545
546 #[async_trait]
547 impl InferenceBackend for OpenAiBackend {
548 async fn send_message_with_tools(
549 &self,
550 text: &str,
551 tools: &[ToolSpec],
552 sink: Option<&TokenSink>,
553 ) -> Result<TurnResult, BackendError> {
554 self.history
555 .lock()
556 .await
557 .push(serde_json::json!({ "role": "user", "content": text }));
558 self.complete(Some(tools), sink).await
559 }
560
561 async fn send_tool_results(
562 &self,
563 results: Vec<ToolResult>,
564 tools: Option<&[ToolSpec]>,
565 sink: Option<&TokenSink>,
566 ) -> Result<TurnResult, BackendError> {
567 {
568 let mut history = self.history.lock().await;
569 for result in results {
570 history.push(serde_json::json!({
571 "role": "tool",
572 "tool_call_id": result.tool_call_id,
573 "content": result.content,
574 }));
575 }
576 }
577 self.complete(tools, sink).await
578 }
579
580 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
581 let mut history = self.history.lock().await;
582 for result in results {
583 history.push(serde_json::json!({
584 "role": "tool",
585 "tool_call_id": result.tool_call_id,
586 "content": result.content,
587 }));
588 }
589 }
590
591 fn is_remote(&self) -> bool {
592 true
593 }
594 }
595
596 // ── OpenAI response shapes ────────────────────────────────────────────────────────
597
598 #[derive(Debug, Deserialize)]
599 struct ChatCompletion {
600 #[serde(default)]
601 choices: Vec<CompletionChoice>,
602 }
603
604 #[derive(Debug, Deserialize)]
605 struct CompletionChoice {
606 message: ResponseMessage,
607 }
608
609 #[derive(Debug, Deserialize)]
610 struct ResponseMessage {
611 #[serde(default)]
612 content: Option<String>,
613 #[serde(default)]
614 tool_calls: Option<Vec<ResponseToolCall>>,
615 }
616
617 impl ResponseMessage {
618 /// Reconstruct the assistant message for replay in history, preserving any
619 /// tool calls so the follow-up request is well-formed.
620 fn into_history_value(self) -> serde_json::Value {
621 let mut message = serde_json::json!({ "role": "assistant" });
622 message["content"] = match self.content {
623 Some(text) => serde_json::Value::String(text),
624 None => serde_json::Value::Null,
625 };
626 if let Some(tool_calls) = self.tool_calls {
627 message["tool_calls"] = serde_json::json!(
628 tool_calls
629 .into_iter()
630 .map(|call| serde_json::json!({
631 "id": call.id,
632 "type": "function",
633 "function": {
634 "name": call.function.name,
635 "arguments": call.function.arguments,
636 }
637 }))
638 .collect::<Vec<_>>()
639 );
640 }
641 message
642 }
643 }
644
645 #[derive(Debug, Deserialize)]
646 struct ResponseToolCall {
647 id: String,
648 function: ResponseFunction,
649 }
650
651 #[derive(Debug, Deserialize)]
652 struct ResponseFunction {
653 name: String,
654 #[serde(default)]
655 arguments: String,
656 }
657
658 // ── OpenAI streaming (SSE) chunk shapes ─────────────────────────────────────────
659
660 #[derive(Debug, Deserialize)]
661 struct StreamCompletion {
662 #[serde(default)]
663 choices: Vec<StreamChoice>,
664 }
665
666 #[derive(Debug, Deserialize)]
667 struct StreamChoice {
668 #[serde(default)]
669 delta: StreamDelta,
670 }
671
672 #[derive(Debug, Default, Deserialize)]
673 struct StreamDelta {
674 #[serde(default)]
675 content: Option<String>,
676 #[serde(default)]
677 tool_calls: Option<Vec<StreamToolCallDelta>>,
678 }
679
680 #[derive(Debug, Deserialize)]
681 struct StreamToolCallDelta {
682 #[serde(default)]
683 index: Option<u32>,
684 #[serde(default)]
685 id: Option<String>,
686 #[serde(default)]
687 function: Option<StreamFunctionDelta>,
688 }
689
690 #[derive(Debug, Deserialize)]
691 struct StreamFunctionDelta {
692 #[serde(default)]
693 name: Option<String>,
694 #[serde(default)]
695 arguments: Option<String>,
696 }
697
698 #[cfg(test)]
699 mod tests {
700 use super::*;
701
702 #[test]
703 fn tools_json_wraps_function_schema() {
704 let tools = vec![ToolSpec {
705 name: "read_file".to_string(),
706 description: "Read a file".to_string(),
707 parameters_schema: r#"{"type":"object","properties":{"path":{"type":"string"}}}"#
708 .to_string(),
709 }];
710 let json = OpenAiBackend::tools_json(&tools);
711 assert_eq!(json[0]["type"], "function");
712 assert_eq!(json[0]["function"]["name"], "read_file");
713 assert_eq!(
714 json[0]["function"]["parameters"]["properties"]["path"]["type"],
715 "string"
716 );
717 }
718
719 #[test]
720 fn malformed_schema_falls_back_to_empty_object() {
721 let tools = vec![ToolSpec {
722 name: "x".to_string(),
723 description: String::new(),
724 parameters_schema: "not json".to_string(),
725 }];
726 let json = OpenAiBackend::tools_json(&tools);
727 assert_eq!(json[0]["function"]["parameters"]["type"], "object");
728 }
729
730 #[test]
731 fn streamed_assistant_history_omits_empty_tool_calls() {
732 let value = streamed_assistant_history("hello", &[]);
733 assert_eq!(value["role"], "assistant");
734 assert_eq!(value["content"], "hello");
735 assert!(value.get("tool_calls").is_none());
736 }
737
738 #[test]
739 fn streamed_assistant_history_preserves_tool_calls() {
740 let calls = vec![ToolCall {
741 id: "call_0".to_string(),
742 name: "read_file".to_string(),
743 arguments: r#"{"path":"a.rs"}"#.to_string(),
744 }];
745 let value = streamed_assistant_history("", &calls);
746 assert!(value["content"].is_null());
747 assert_eq!(value["tool_calls"][0]["id"], "call_0");
748 assert_eq!(value["tool_calls"][0]["type"], "function");
749 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
750 assert_eq!(
751 value["tool_calls"][0]["function"]["arguments"],
752 r#"{"path":"a.rs"}"#
753 );
754 }
755
756 #[tokio::test]
757 async fn cancelled_tool_results_close_out_history() {
758 let backend = OpenAiBackend::new("http://localhost", "", "test-model", None);
759 backend
760 .history
761 .lock()
762 .await
763 .push(streamed_assistant_history(
764 "",
765 &[ToolCall {
766 id: "call_9".to_string(),
767 name: "run_command".to_string(),
768 arguments: r#"{"command":"ls"}"#.to_string(),
769 }],
770 ));
771
772 backend
773 .record_cancelled_tool_results(vec![ToolResult {
774 tool_call_id: "call_9".to_string(),
775 content: "cancelled by the user".to_string(),
776 }])
777 .await;
778
779 let history = backend.history.lock().await;
780 let last = history.last().unwrap();
781 assert_eq!(last["role"], "tool");
782 assert_eq!(last["tool_call_id"], "call_9");
783 assert_eq!(last["content"], "cancelled by the user");
784 }
785
786 #[test]
787 fn assistant_message_with_tool_calls_round_trips() {
788 let message = ResponseMessage {
789 content: None,
790 tool_calls: Some(vec![ResponseToolCall {
791 id: "call_1".to_string(),
792 function: ResponseFunction {
793 name: "read_file".to_string(),
794 arguments: r#"{"path":"a.rs"}"#.to_string(),
795 },
796 }]),
797 };
798 let value = message.into_history_value();
799 assert_eq!(value["role"], "assistant");
800 assert!(value["content"].is_null());
801 assert_eq!(value["tool_calls"][0]["id"], "call_1");
802 assert_eq!(value["tool_calls"][0]["type"], "function");
803 assert_eq!(value["tool_calls"][0]["function"]["name"], "read_file");
804 }
805 }