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.
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
}
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
}
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 {