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