1pub mod streaming;
2
3use super::{Agent, hook::AgentHook, run::OutputMode, runner::AgentRunner};
4use rig_core::{
5 completion::FinishReason,
6 message::{
7 AssistantContent, ProviderCallId, ToolCallId, ToolResultContent, UserContent, non_empty,
8 },
9 wasm_compat::{WasmBoxedFuture, WasmCompatSend},
10};
11
12use crate::{
13 completion::{Message, PromptError, Usage},
14 tool::{ToolContext, ToolOutput},
15};
16use serde::{Deserialize, Serialize};
17use std::{future::IntoFuture, marker::PhantomData};
18
19pub use rig_core::completion::ResponseIdentity;
23
24macro_rules! forward_prompt_setters {
33 ($recv:ident) => {
34 pub fn tool_context(mut self, context: ToolContext) -> Self {
41 self.$recv = self.$recv.tool_context(context);
42 self
43 }
44
45 pub fn history<H, Item>(mut self, history: H) -> Self
47 where
48 H: IntoIterator<Item = Item>,
49 Item: Into<Message>,
50 {
51 self.$recv = self.$recv.history(history);
52 self
53 }
54
55 pub fn preamble(mut self, preamble: impl Into<String>) -> Self {
57 self.$recv = self.$recv.preamble(preamble);
58 self
59 }
60
61 pub fn without_preamble(mut self) -> Self {
63 self.$recv = self.$recv.without_preamble();
64 self
65 }
66
67 pub fn document(mut self, document: crate::completion::Document) -> Self {
69 self.$recv = self.$recv.document(document);
70 self
71 }
72
73 pub fn documents(
75 mut self,
76 documents: impl IntoIterator<Item = crate::completion::Document>,
77 ) -> Self {
78 self.$recv = self.$recv.documents(documents);
79 self
80 }
81
82 pub fn temperature(mut self, temperature: f64) -> Self {
84 self.$recv = self.$recv.temperature(temperature);
85 self
86 }
87
88 pub fn without_temperature(mut self) -> Self {
90 self.$recv = self.$recv.without_temperature();
91 self
92 }
93
94 pub fn max_tokens(mut self, max_tokens: u64) -> Self {
96 self.$recv = self.$recv.max_tokens(max_tokens);
97 self
98 }
99
100 pub fn without_max_tokens(mut self) -> Self {
102 self.$recv = self.$recv.without_max_tokens();
103 self
104 }
105
106 pub fn merge_additional_params(
109 mut self,
110 params: serde_json::Map<String, serde_json::Value>,
111 ) -> Self {
112 self.$recv = self.$recv.merge_additional_params(params);
113 self
114 }
115
116 pub fn replace_additional_params(mut self, params: serde_json::Value) -> Self {
118 self.$recv = self.$recv.replace_additional_params(params);
119 self
120 }
121
122 pub fn without_additional_params(mut self) -> Self {
124 self.$recv = self.$recv.without_additional_params();
125 self
126 }
127
128 pub fn tool_choice(mut self, tool_choice: rig_core::message::ToolChoice) -> Self {
130 self.$recv = self.$recv.tool_choice(tool_choice);
131 self
132 }
133
134 pub fn without_tool_choice(mut self) -> Self {
136 self.$recv = self.$recv.without_tool_choice();
137 self
138 }
139
140 pub fn record_content_telemetry(mut self, enabled: bool) -> Self {
151 self.$recv = self.$recv.record_content_telemetry(enabled);
152 self
153 }
154
155 pub fn conversation(mut self, id: impl Into<String>) -> Self {
160 self.$recv = self.$recv.conversation(id);
161 self
162 }
163
164 pub fn without_memory(mut self) -> Self {
168 self.$recv = self.$recv.without_memory();
169 self
170 }
171
172 pub fn max_invalid_tool_call_retries(mut self, retries: usize) -> Self {
176 self.$recv = self.$recv.max_invalid_tool_call_retries(retries);
177 self
178 }
179
180 pub fn using_model(mut self, model: $crate::agent::ModelHandle) -> Self {
185 self.$recv = self.$recv.using_model(model);
186 self
187 }
188
189 pub fn using_model_value<M>(mut self, model: M) -> Self
191 where
192 M: $crate::completion::CompletionModel + 'static,
193 {
194 self.$recv = self.$recv.using_model_value(model);
195 self
196 }
197 };
198}
199pub(crate) use forward_prompt_setters;
200
201macro_rules! forward_tool_concurrency {
206 ($recv:ident) => {
207 pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
214 self.$recv = self.$recv.tool_concurrency(concurrency);
215 self
216 }
217 };
218}
219
220pub trait PromptType {}
221pub struct Standard;
222pub struct Extended;
223
224impl PromptType for Standard {}
225impl PromptType for Extended {}
226
227pub struct PromptRequest<S>
235where
236 S: PromptType,
237{
238 pub(crate) runner: AgentRunner,
240 state: PhantomData<S>,
242}
243
244impl PromptRequest<Standard> {
245 pub fn from_agent(agent: &Agent, prompt: impl Into<Message>) -> Self {
248 PromptRequest {
249 runner: AgentRunner::from_agent(agent, prompt),
250 state: PhantomData,
251 }
252 }
253}
254
255impl<S> PromptRequest<S>
256where
257 S: PromptType,
258{
259 pub fn extended_details(self) -> PromptRequest<Extended> {
266 PromptRequest {
267 runner: self.runner,
268 state: PhantomData,
269 }
270 }
271
272 pub fn max_turns(mut self, max_turns: usize) -> Self {
277 self.runner = self.runner.max_turns(max_turns);
278 self
279 }
280
281 pub fn add_hook<H>(mut self, hook: H) -> Self
289 where
290 H: AgentHook + 'static,
291 {
292 self.runner = self.runner.add_hook(hook);
293 self
294 }
295
296 forward_prompt_setters!(runner);
297 forward_tool_concurrency!(runner);
298}
299
300impl IntoFuture for PromptRequest<Standard> {
304 type Output = Result<String, PromptError>;
305 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
306
307 fn into_future(self) -> Self::IntoFuture {
308 Box::pin(self.send())
309 }
310}
311
312impl IntoFuture for PromptRequest<Extended> {
313 type Output = Result<PromptResponse, PromptError>;
314 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
315
316 fn into_future(self) -> Self::IntoFuture {
317 Box::pin(self.send())
318 }
319}
320
321impl PromptRequest<Standard> {
322 async fn send(self) -> Result<String, PromptError> {
323 self.extended_details().send().await.map(|resp| resp.output)
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331pub struct CompletionCall {
332 pub call_index: usize,
334 #[serde(default, deserialize_with = "usage_null_as_default")]
340 pub usage: Usage,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub message_id: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub response_id: Option<String>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub provider_request_id: Option<String>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub finish_reason: Option<FinishReason>,
367 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
380 pub raw: serde_json::Value,
381}
382
383impl CompletionCall {
384 pub fn new(call_index: usize, usage: Usage) -> Self {
387 Self {
388 call_index,
389 usage,
390 message_id: None,
391 response_id: None,
392 provider_request_id: None,
393 finish_reason: None,
394 raw: serde_json::Value::Null,
395 }
396 }
397
398 pub fn with_raw(mut self, raw: serde_json::Value) -> Self {
400 self.raw = raw;
401 self
402 }
403
404 pub fn with_identity(mut self, identity: ResponseIdentity) -> Self {
406 self.message_id = identity.message_id;
407 self.response_id = identity.response_id;
408 self.provider_request_id = identity.provider_request_id;
409 self
410 }
411
412 pub fn with_finish_reason(mut self, finish_reason: Option<FinishReason>) -> Self {
418 self.finish_reason = finish_reason;
419 self
420 }
421
422 pub fn identity(&self) -> ResponseIdentity {
424 ResponseIdentity {
425 message_id: self.message_id.clone(),
426 response_id: self.response_id.clone(),
427 provider_request_id: self.provider_request_id.clone(),
428 }
429 }
430}
431
432fn usage_null_as_default<'de, D>(deserializer: D) -> Result<Usage, D::Error>
439where
440 D: serde::Deserializer<'de>,
441{
442 Ok(Option::<Usage>::deserialize(deserializer)?.unwrap_or_default())
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct PromptResponse {
456 pub output: String,
458 pub usage: Usage,
460 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 pub completion_calls: Vec<CompletionCall>,
468 pub messages: Option<Vec<Message>>,
471 pub content: Vec<AssistantContent>,
476 #[serde(skip)]
480 output_tool_calls: usize,
481}
482
483impl std::fmt::Display for PromptResponse {
484 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485 self.output.fmt(f)
486 }
487}
488
489impl PromptResponse {
490 pub fn new(output: impl Into<String>, usage: Usage) -> Self {
491 let output = output.into();
492 Self {
493 content: vec![AssistantContent::text(output.clone())],
494 output,
495 usage,
496 completion_calls: Vec::new(),
497 messages: None,
498 output_tool_calls: 0,
499 }
500 }
501
502 pub fn empty() -> Self {
504 Self::new(String::new(), Usage::new())
505 }
506
507 pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
508 self.messages = Some(messages);
509 self
510 }
511
512 pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
514 self.completion_calls = completion_calls;
515 self
516 }
517
518 pub fn with_content(mut self, content: Vec<AssistantContent>) -> Self {
520 self.content = content;
521 self
522 }
523
524 pub(crate) fn with_output_tool_calls(mut self, count: usize) -> Self {
525 self.output_tool_calls = count;
526 self
527 }
528
529 pub(crate) fn output_tool_calls(&self) -> usize {
530 self.output_tool_calls
531 }
532
533 pub fn output(&self) -> &str {
535 &self.output
536 }
537
538 pub fn usage(&self) -> Usage {
540 self.usage
541 }
542
543 pub fn messages(&self) -> Option<&[Message]> {
545 self.messages.as_deref()
546 }
547
548 pub fn content(&self) -> &[AssistantContent] {
550 &self.content
551 }
552
553 pub fn completion_calls(&self) -> &[CompletionCall] {
558 &self.completion_calls
559 }
560
561 pub fn requests(&self) -> usize {
563 self.completion_calls.len()
564 }
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct TypedPromptResponse<T> {
569 pub output: T,
570 pub usage: Usage,
571 #[serde(default, skip_serializing_if = "Vec::is_empty")]
578 pub completion_calls: Vec<CompletionCall>,
579}
580
581impl<T> TypedPromptResponse<T> {
582 pub fn new(output: T, usage: Usage) -> Self {
583 Self {
584 output,
585 usage,
586 completion_calls: Vec::new(),
587 }
588 }
589
590 pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
592 self.completion_calls = completion_calls;
593 self
594 }
595
596 pub fn completion_calls(&self) -> &[CompletionCall] {
601 &self.completion_calls
602 }
603
604 pub fn requests(&self) -> usize {
606 self.completion_calls.len()
607 }
608}
609
610pub(crate) const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
611 "Tool not executed because another tool call in the same assistant turn was invalid.";
612
613pub(crate) fn build_history_for_request(
615 chat_history: Option<&[Message]>,
616 new_messages: &[Message],
617) -> Vec<Message> {
618 let input = chat_history.unwrap_or(&[]);
619 input.iter().chain(new_messages.iter()).cloned().collect()
620}
621
622pub(crate) fn build_full_history(
624 chat_history: Option<&[Message]>,
625 new_messages: Vec<Message>,
626) -> Vec<Message> {
627 let input = chat_history.unwrap_or(&[]);
628 input.iter().cloned().chain(new_messages).collect()
629}
630
631fn tool_result_with(
634 call: ToolCallId,
635 provider: Option<ProviderCallId>,
636 name: String,
637 content: Vec<ToolResultContent>,
638) -> UserContent {
639 UserContent::tool_result_for(call, provider, name, content)
643}
644
645pub(crate) fn tool_result_output(
647 call: ToolCallId,
648 provider: Option<ProviderCallId>,
649 name: String,
650 output: ToolOutput,
651) -> UserContent {
652 tool_result_with(call, provider, name, output.into_content())
653}
654
655pub(crate) fn tool_result_message(
661 call: ToolCallId,
662 provider: Option<ProviderCallId>,
663 name: String,
664 message: String,
665) -> UserContent {
666 tool_result_with(call, provider, name, vec![ToolResultContent::text(message)])
667}
668
669pub(crate) fn invalid_tool_retry_user_message(
670 assistant_content: &[AssistantContent],
671 invalid_tool_call_id: &ToolCallId,
672 feedback: String,
673) -> Option<Message> {
674 let retry_results = assistant_content
679 .iter()
680 .filter_map(|content| match content {
681 AssistantContent::ToolCall(tool_call) if tool_call.id == *invalid_tool_call_id => {
682 Some(tool_result_message(
683 tool_call.id.clone(),
684 tool_call.provider.clone(),
685 tool_call.function.name.clone(),
686 feedback.clone(),
687 ))
688 }
689 AssistantContent::ToolCall(tool_call) => Some(tool_result_message(
690 tool_call.id.clone(),
691 tool_call.provider.clone(),
692 tool_call.function.name.clone(),
693 TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
694 )),
695 _ => None,
696 })
697 .collect::<Vec<_>>();
698
699 Some(Message::User {
700 content: non_empty(retry_results)?,
701 })
702}
703
704pub(crate) fn is_empty_assistant_turn(choice: &[AssistantContent]) -> bool {
725 if choice.is_empty() {
726 return true;
727 }
728
729 choice.len() == 1
730 && matches!(
731 choice.first(),
732 Some(AssistantContent::Text(text))
733 if text.text.is_empty() && text.additional_params.is_none()
734 )
735}
736
737pub(crate) fn turn_delivered_no_answer(choice: &[AssistantContent]) -> bool {
777 !choice.iter().any(|content| match content {
778 AssistantContent::Text(text) => !text.text.is_empty(),
780 AssistantContent::ToolCall(_) => true,
781 AssistantContent::Image(_) => true,
782 AssistantContent::Reasoning(_) => false,
784 })
785}
786
787pub(crate) fn assistant_text_from_choice(choice: &[AssistantContent]) -> String {
788 choice
789 .iter()
790 .filter_map(|content| match content {
791 AssistantContent::Text(text) => Some(text.text.as_str()),
792 _ => None,
793 })
794 .collect()
795}
796
797impl PromptRequest<Extended> {
798 async fn send(self) -> Result<PromptResponse, PromptError> {
799 self.runner.run().await
800 }
801}
802
803use crate::completion::StructuredOutputError;
808use schemars::{JsonSchema, schema_for};
809use serde::de::DeserializeOwned;
810
811pub struct TypedPromptRequest<T, S>
828where
829 T: JsonSchema + DeserializeOwned + WasmCompatSend,
830 S: PromptType,
831{
832 inner: PromptRequest<S>,
833 _phantom: std::marker::PhantomData<T>,
834}
835
836impl<T> TypedPromptRequest<T, Standard>
837where
838 T: JsonSchema + DeserializeOwned + WasmCompatSend,
839{
840 pub fn from_agent(agent: &Agent, prompt: impl Into<Message>) -> Self {
844 let mut inner = PromptRequest::from_agent(agent, prompt);
845 inner.runner.config.output_schema = Some(schema_for!(T));
847 inner.runner.config.output_mode = OutputMode::Native;
854 Self {
855 inner,
856 _phantom: std::marker::PhantomData,
857 }
858 }
859}
860
861impl<T, S> TypedPromptRequest<T, S>
862where
863 T: JsonSchema + DeserializeOwned + WasmCompatSend,
864 S: PromptType,
865{
866 pub fn extended_details(self) -> TypedPromptRequest<T, Extended> {
872 TypedPromptRequest {
873 inner: self.inner.extended_details(),
874 _phantom: std::marker::PhantomData,
875 }
876 }
877
878 pub fn max_turns(mut self, max_turns: usize) -> Self {
883 self.inner = self.inner.max_turns(max_turns);
884 self
885 }
886
887 pub fn add_hook<H>(mut self, hook: H) -> Self
890 where
891 H: AgentHook + 'static,
892 {
893 self.inner = self.inner.add_hook(hook);
894 self
895 }
896
897 forward_prompt_setters!(inner);
898 forward_tool_concurrency!(inner);
899}
900
901fn deserialize_structured_output<T: DeserializeOwned>(text: &str) -> Result<T, serde_json::Error> {
908 let trimmed = text.trim();
909 match serde_json::from_str::<T>(trimmed) {
910 Ok(value) => Ok(value),
911 Err(direct_err) => {
912 let Some(start) = trimmed.find(['{', '[']) else {
913 return Err(direct_err);
914 };
915 serde_json::Deserializer::from_str(&trimmed[start..])
916 .into_iter::<T>()
917 .next()
918 .unwrap_or(Err(direct_err))
919 }
920 }
921}
922
923impl<T> TypedPromptRequest<T, Standard>
924where
925 T: JsonSchema + DeserializeOwned + WasmCompatSend,
926{
927 async fn send(self) -> Result<T, StructuredOutputError> {
929 let response = self.inner.send().await.map_err(Box::new)?;
930
931 if response.is_empty() {
932 return Err(StructuredOutputError::EmptyResponse);
933 }
934
935 let parsed: T = deserialize_structured_output(&response)?;
936 Ok(parsed)
937 }
938}
939
940impl<T> TypedPromptRequest<T, Extended>
941where
942 T: JsonSchema + DeserializeOwned + WasmCompatSend,
943{
944 async fn send(self) -> Result<TypedPromptResponse<T>, StructuredOutputError> {
946 let response = self.inner.send().await.map_err(Box::new)?;
947
948 if response.output.is_empty() {
949 return Err(StructuredOutputError::EmptyResponse);
950 }
951
952 let parsed: T = deserialize_structured_output(&response.output)?;
953 Ok(TypedPromptResponse::new(parsed, response.usage)
954 .with_completion_calls(response.completion_calls))
955 }
956}
957
958impl<T> IntoFuture for TypedPromptRequest<T, Standard>
959where
960 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
961{
962 type Output = Result<T, StructuredOutputError>;
963 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
964
965 fn into_future(self) -> Self::IntoFuture {
966 Box::pin(self.send())
967 }
968}
969
970impl<T> IntoFuture for TypedPromptRequest<T, Extended>
971where
972 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
973{
974 type Output = Result<TypedPromptResponse<T>, StructuredOutputError>;
975 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
976
977 fn into_future(self) -> Self::IntoFuture {
978 Box::pin(self.send())
979 }
980}
981#[cfg(test)]
982mod tests {
983 use super::ResponseIdentity;
984 use super::{
985 CompletionCall, PromptResponse, TypedPromptResponse, assistant_text_from_choice,
986 is_empty_assistant_turn, turn_delivered_no_answer,
987 };
988 use crate::{
989 agent::{
990 AgentBuilder,
991 hook::{
992 AgentHook, CompletionResponse as CompletionResponseEvent, HookContext,
993 InvalidToolCallAction, InvalidToolCallContext, ObservationAction,
994 ToolCall as ToolCallEvent, ToolCallAction,
995 },
996 },
997 completion::{
998 AssistantContent, CompletionError, CompletionRequest, FinishReason, Message, Prompt,
999 PromptError, StructuredOutputError, TypedPrompt, Usage,
1000 },
1001 test_utils::{
1002 AppendFailingMemory, CountingMemory, FailingMemory, MockAddTool, MockCompletionModel,
1003 MockContextProbeTool, MockOperationArgs, MockSubtractTool, MockToolError, MockTurn,
1004 SessionId,
1005 },
1006 tool::{Tool, ToolContext},
1007 };
1008 use rig_core::message::ProviderCallId;
1009 use rig_core::message::{Text, ToolCall, ToolChoice, ToolFunction, UserContent};
1010 use schemars::JsonSchema;
1011 use serde::{Deserialize, Serialize};
1012 use serde_json::json;
1013 use std::sync::{
1014 Arc, Mutex,
1015 atomic::{AtomicU32, Ordering},
1016 };
1017
1018 #[tokio::test]
1029 async fn blocking_prompt_rejects_an_empty_truncated_turn() {
1030 let agent = AgentBuilder::new(MockCompletionModel::from_turns([MockTurn::from_contents(
1031 [],
1032 )
1033 .with_finish_reason(FinishReason::Length)]))
1034 .build();
1035
1036 let err = agent
1037 .prompt("write a long essay")
1038 .await
1039 .expect_err("a content-less truncated turn must not return an empty answer");
1040
1041 let rendered = format!("{err:?}");
1042 assert!(
1043 rendered.contains("Length") && rendered.contains("max_tokens"),
1044 "the blocking error must name the reason and the remedy: {rendered}"
1045 );
1046 }
1047
1048 #[tokio::test]
1051 async fn blocking_prompt_rejects_a_reasoning_only_truncated_turn() {
1052 let agent = AgentBuilder::new(MockCompletionModel::from_turns([MockTurn::from_content(
1053 AssistantContent::Reasoning(rig_core::message::Reasoning::new(
1054 "thinking, never answering",
1055 )),
1056 )
1057 .with_finish_reason(FinishReason::Length)]))
1058 .build();
1059
1060 let err = agent
1061 .prompt("solve this")
1062 .await
1063 .expect_err("a reasoning-only truncated turn must not return an empty answer");
1064
1065 assert!(format!("{err:?}").contains("Length"));
1066 }
1067
1068 #[tokio::test]
1075 async fn blocking_prompt_keeps_partial_output_and_records_the_reason() {
1076 let agent = AgentBuilder::new(MockCompletionModel::from_turns([MockTurn::text(
1077 "a partial ans",
1078 )
1079 .with_finish_reason(FinishReason::Length)]))
1080 .build();
1081
1082 let response = agent
1083 .prompt("write a long essay")
1084 .extended_details()
1085 .await
1086 .expect("a truncated turn that produced text must still succeed");
1087
1088 assert_eq!(response.output, "a partial ans");
1089 assert_eq!(
1090 response
1091 .completion_calls
1092 .last()
1093 .and_then(|call| call.finish_reason.clone()),
1094 Some(FinishReason::Length),
1095 "the terminal reason must reach the caller on the blocking surface too"
1096 );
1097 }
1098
1099 #[test]
1112 fn answer_classification_covers_every_assistant_content_variant() {
1113 let image = AssistantContent::image_base64(
1114 "iVBORw0KGgo=",
1115 Some(rig_core::message::ImageMediaType::PNG),
1116 Some(rig_core::message::ImageDetail::default()),
1117 );
1118 let reasoning = AssistantContent::Reasoning(rig_core::message::Reasoning::new("thinking"));
1119 let tool_call = AssistantContent::ToolCall(ToolCall::from_wire(
1120 "call_1".to_string(),
1121 ToolFunction::new("add".to_string(), json!({})),
1122 ));
1123
1124 assert!(!turn_delivered_no_answer(&[AssistantContent::text("hi")]));
1126 assert!(!turn_delivered_no_answer(std::slice::from_ref(&tool_call)));
1127 assert!(
1128 !turn_delivered_no_answer(std::slice::from_ref(&image)),
1129 "an image IS the answer for an image-generation turn; classifying it \
1130 as 'no answer' makes a truncated image turn error despite delivering one"
1131 );
1132
1133 assert!(turn_delivered_no_answer(&[]));
1135 assert!(turn_delivered_no_answer(&[AssistantContent::text("")]));
1136 assert!(
1137 turn_delivered_no_answer(std::slice::from_ref(&reasoning)),
1138 "reasoning is scratch work, not an answer"
1139 );
1140
1141 assert!(!turn_delivered_no_answer(&[
1143 reasoning.clone(),
1144 AssistantContent::text("answer")
1145 ]));
1146 assert!(
1147 !turn_delivered_no_answer(&[reasoning.clone(), image.clone()]),
1148 "a thinking image model that produced an image has answered"
1149 );
1150 assert!(turn_delivered_no_answer(&[
1151 reasoning,
1152 AssistantContent::text("")
1153 ]));
1154 }
1155
1156 #[test]
1170 fn the_two_turn_predicates_diverge_on_recordable_but_answerless_turns() {
1171 let reasoning_only = vec![AssistantContent::Reasoning(
1172 rig_core::message::Reasoning::new("t"),
1173 )];
1174 let annotated_empty_text = vec![AssistantContent::Text(Text {
1175 text: String::new(),
1176 additional_params: rig_core::message::AdditionalParams::try_from_value(
1177 json!({"citations": ["ref"]}),
1178 )
1179 .expect("citation params should be a JSON object"),
1180 })];
1181
1182 for choice in [&reasoning_only, &annotated_empty_text] {
1184 assert!(
1185 !is_empty_assistant_turn(choice),
1186 "should be recorded in history: {choice:?}"
1187 );
1188 assert!(
1189 turn_delivered_no_answer(choice),
1190 "should count as no answer — the caller receives no text: {choice:?}"
1191 );
1192 }
1193
1194 for choice in [
1196 vec![],
1197 vec![AssistantContent::text("")],
1198 vec![AssistantContent::text("real")],
1199 vec![AssistantContent::image_base64(
1200 "iVBORw0KGgo=",
1201 Some(rig_core::message::ImageMediaType::PNG),
1202 Some(rig_core::message::ImageDetail::default()),
1203 )],
1204 vec![AssistantContent::ToolCall(ToolCall::from_wire(
1205 "call_1".to_string(),
1206 ToolFunction::new("add".to_string(), json!({})),
1207 ))],
1208 ] {
1209 assert_eq!(
1210 is_empty_assistant_turn(&choice),
1211 turn_delivered_no_answer(&choice),
1212 "unexpected divergence on {choice:?}"
1213 );
1214 }
1215 }
1216
1217 #[test]
1230 fn metadata_only_text_is_not_an_answer() {
1231 let annotated_empty = AssistantContent::Text(Text {
1232 text: String::new(),
1233 additional_params: rig_core::message::AdditionalParams::try_from_value(
1234 json!({"citations": ["ref"]}),
1235 )
1236 .expect("citation params should be a JSON object"),
1237 });
1238
1239 assert!(turn_delivered_no_answer(std::slice::from_ref(
1240 &annotated_empty
1241 )));
1242 assert_eq!(
1243 assistant_text_from_choice(std::slice::from_ref(&annotated_empty)),
1244 "",
1245 "the caller receives no text, which is why this is not an answer"
1246 );
1247
1248 assert!(!turn_delivered_no_answer(&[
1250 annotated_empty,
1251 AssistantContent::text("real"),
1252 ]));
1253 }
1254
1255 #[derive(Serialize)]
1256 struct SerializeOnly {
1257 value: &'static str,
1258 }
1259
1260 #[derive(Deserialize)]
1261 struct DeserializeOnly {
1262 value: String,
1263 }
1264
1265 #[derive(Debug, Deserialize, JsonSchema, PartialEq)]
1266 struct TypedAnswer {
1267 value: String,
1268 }
1269
1270 #[test]
1271 fn deserialize_structured_output_tolerates_fences_and_prose() {
1272 assert_eq!(
1274 super::deserialize_structured_output::<TypedAnswer>(r#"{"value":"x"}"#).unwrap(),
1275 TypedAnswer { value: "x".into() }
1276 );
1277 assert_eq!(
1279 super::deserialize_structured_output::<TypedAnswer>("```json\n{\"value\":\"y\"}\n```")
1280 .unwrap(),
1281 TypedAnswer { value: "y".into() }
1282 );
1283 assert_eq!(
1285 super::deserialize_structured_output::<TypedAnswer>(
1286 "Here you go: {\"value\":\"z\"} — hope that helps!"
1287 )
1288 .unwrap(),
1289 TypedAnswer { value: "z".into() }
1290 );
1291 assert!(super::deserialize_structured_output::<TypedAnswer>("no json here").is_err());
1293 }
1294
1295 #[derive(Clone)]
1296 struct PanicOnUnknownToolHook;
1297
1298 impl AgentHook for PanicOnUnknownToolHook {
1299 async fn on_completion_response(
1300 &self,
1301 _ctx: &HookContext,
1302 _event: CompletionResponseEvent<'_>,
1303 ) -> ObservationAction {
1304 panic!("unknown tool response should fail before response hooks run")
1305 }
1306 async fn on_tool_call(
1307 &self,
1308 _ctx: &HookContext,
1309 _event: ToolCallEvent<'_>,
1310 ) -> ToolCallAction {
1311 panic!("unknown tool call should fail before tool hooks run")
1312 }
1313 }
1314
1315 #[derive(Clone)]
1316 struct PanicOnToolCallHook;
1317
1318 impl AgentHook for PanicOnToolCallHook {
1319 async fn on_tool_call(
1320 &self,
1321 _ctx: &HookContext,
1322 _event: ToolCallEvent<'_>,
1323 ) -> ToolCallAction {
1324 panic!("recovered invalid turn should not invoke normal tool hooks")
1325 }
1326 }
1327
1328 #[derive(Clone)]
1329 struct SkipDefaultApiAndPanicOnToolCallHook;
1330
1331 impl AgentHook for SkipDefaultApiAndPanicOnToolCallHook {
1332 async fn on_invalid_tool_call(
1333 &self,
1334 ctx: &HookContext,
1335 event: &InvalidToolCallContext,
1336 ) -> Option<InvalidToolCallAction> {
1337 SkipDefaultApiHook.on_invalid_tool_call(ctx, event).await
1338 }
1339 async fn on_tool_call(
1340 &self,
1341 ctx: &HookContext,
1342 event: ToolCallEvent<'_>,
1343 ) -> ToolCallAction {
1344 PanicOnToolCallHook.on_tool_call(ctx, event).await
1345 }
1346 }
1347
1348 #[derive(Clone)]
1349 struct RepairDefaultApiHook;
1350
1351 impl AgentHook for RepairDefaultApiHook {
1352 async fn on_invalid_tool_call(
1353 &self,
1354 _ctx: &HookContext,
1355 event: &InvalidToolCallContext,
1356 ) -> Option<InvalidToolCallAction> {
1357 assert_eq!(event.tool_name, "default_api");
1358 Some(InvalidToolCallAction::repair("add"))
1359 }
1360 }
1361
1362 #[derive(Clone)]
1363 struct RepairToSubtractHook;
1364
1365 impl AgentHook for RepairToSubtractHook {
1366 async fn on_invalid_tool_call(
1367 &self,
1368 _ctx: &HookContext,
1369 _event: &InvalidToolCallContext,
1370 ) -> Option<InvalidToolCallAction> {
1371 Some(InvalidToolCallAction::repair("subtract"))
1372 }
1373 }
1374
1375 #[derive(Clone)]
1376 struct RetryDefaultApiHook;
1377
1378 impl AgentHook for RetryDefaultApiHook {
1379 async fn on_invalid_tool_call(
1380 &self,
1381 _ctx: &HookContext,
1382 event: &InvalidToolCallContext,
1383 ) -> Option<InvalidToolCallAction> {
1384 Some(InvalidToolCallAction::retry(format!(
1385 "Use one of these tools instead: {:?}",
1386 event.allowed_tools
1387 )))
1388 }
1389 }
1390
1391 #[derive(Clone)]
1392 struct SkipDefaultApiHook;
1393
1394 impl AgentHook for SkipDefaultApiHook {
1395 async fn on_invalid_tool_call(
1396 &self,
1397 _ctx: &HookContext,
1398 _event: &InvalidToolCallContext,
1399 ) -> Option<InvalidToolCallAction> {
1400 Some(InvalidToolCallAction::skip("default_api is not available"))
1401 }
1402 }
1403
1404 #[derive(Clone, Default)]
1405 struct RecordingInvalidToolCallHook {
1406 contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
1407 }
1408
1409 impl RecordingInvalidToolCallHook {
1410 fn observed(&self) -> Vec<InvalidToolCallContext> {
1411 self.contexts
1412 .lock()
1413 .expect("invalid tool context records mutex was poisoned")
1414 .clone()
1415 }
1416 }
1417
1418 impl AgentHook for RecordingInvalidToolCallHook {
1419 async fn on_invalid_tool_call(
1420 &self,
1421 _ctx: &HookContext,
1422 event: &InvalidToolCallContext,
1423 ) -> Option<InvalidToolCallAction> {
1424 self.contexts
1425 .lock()
1426 .expect("invalid tool context records mutex was poisoned")
1427 .push(event.clone());
1428 None
1429 }
1430 }
1431
1432 #[derive(Clone)]
1433 struct CountingAddTool {
1434 calls: Arc<AtomicU32>,
1435 }
1436
1437 impl Tool for CountingAddTool {
1438 const NAME: &'static str = "add";
1439 type Error = MockToolError;
1440 type Args = MockOperationArgs;
1441 type Output = i32;
1442
1443 fn description(&self) -> String {
1444 MockAddTool.description()
1445 }
1446
1447 fn parameters(&self) -> serde_json::Value {
1448 MockAddTool.parameters()
1449 }
1450
1451 async fn call(
1452 &self,
1453 _context: &mut crate::tool::ToolContext,
1454 _args: Self::Args,
1455 ) -> Result<Self::Output, Self::Error> {
1456 self.calls.fetch_add(1, Ordering::SeqCst);
1457 Ok(0)
1458 }
1459 }
1460
1461 fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
1462 Usage {
1463 input_tokens,
1464 output_tokens,
1465 total_tokens: input_tokens + output_tokens,
1466 cached_input_tokens: 0,
1467 cache_creation_input_tokens: 0,
1468 tool_use_prompt_tokens: 0,
1469 reasoning_tokens: 0,
1470 }
1471 }
1472
1473 #[test]
1474 fn typed_prompt_response_serializes_with_serialize_only_output() {
1475 let response = TypedPromptResponse::new(
1476 SerializeOnly { value: "ok" },
1477 Usage {
1478 input_tokens: 1,
1479 output_tokens: 2,
1480 total_tokens: 3,
1481 cached_input_tokens: 0,
1482 cache_creation_input_tokens: 0,
1483 tool_use_prompt_tokens: 0,
1484 reasoning_tokens: 0,
1485 },
1486 );
1487
1488 let json = serde_json::to_string(&response).expect("serialize typed prompt response");
1489 assert!(json.contains("\"value\":\"ok\""));
1490 }
1491
1492 #[test]
1493 fn typed_prompt_response_deserializes_with_deserialize_only_output() {
1494 let response: TypedPromptResponse<DeserializeOnly> = serde_json::from_str(
1495 r#"{"output":{"value":"ok"},"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3,"cached_input_tokens":0,"cache_creation_input_tokens":0,"reasoning_tokens":0}}"#,
1496 )
1497 .expect("deserialize typed prompt response");
1498
1499 assert_eq!(response.requests(), 0);
1500 assert_eq!(response.output.value, "ok");
1501 assert_eq!(response.usage.input_tokens, 1);
1502 assert_eq!(response.usage.output_tokens, 2);
1503 assert_eq!(response.usage.total_tokens, 3);
1504 }
1505
1506 #[test]
1507 fn prompt_response_serializes_completion_calls_with_missing_usage() {
1508 let reported_usage = usage(3, 4);
1509 let response = PromptResponse::new("ok", reported_usage).with_completion_calls(vec![
1510 CompletionCall::new(0, Usage::new()),
1511 CompletionCall::new(1, reported_usage),
1512 ]);
1513
1514 let value = serde_json::to_value(&response).expect("serialize prompt response");
1515
1516 assert_eq!(
1520 value.get("completion_calls"),
1521 Some(&json!([
1522 {
1523 "call_index": 0,
1524 "usage": {
1525 "input_tokens": 0,
1526 "output_tokens": 0,
1527 "total_tokens": 0,
1528 "cached_input_tokens": 0,
1529 "cache_creation_input_tokens": 0,
1530 "tool_use_prompt_tokens": 0,
1531 "reasoning_tokens": 0,
1532 }
1533 },
1534 {
1535 "call_index": 1,
1536 "usage": {
1537 "input_tokens": 3,
1538 "output_tokens": 4,
1539 "total_tokens": 7,
1540 "cached_input_tokens": 0,
1541 "cache_creation_input_tokens": 0,
1542 "tool_use_prompt_tokens": 0,
1543 "reasoning_tokens": 0,
1544 }
1545 }
1546 ]))
1547 );
1548
1549 let response: PromptResponse =
1550 serde_json::from_value(value).expect("deserialize prompt response");
1551 assert_eq!(
1552 response.completion_calls(),
1553 &[
1554 CompletionCall::new(0, Usage::new()),
1555 CompletionCall::new(1, reported_usage)
1556 ]
1557 );
1558 assert_eq!(response.requests(), 2);
1559 }
1560
1561 #[test]
1562 fn prompt_response_output_tool_marker_is_never_serialized() {
1563 let response = PromptResponse::new("ok", usage(1, 2)).with_output_tool_calls(3);
1564
1565 let value = serde_json::to_value(&response).expect("serialize prompt response");
1566 assert!(value.get("output_tool_calls").is_none());
1567
1568 let decoded: PromptResponse =
1569 serde_json::from_value(value).expect("deserialize prompt response");
1570 assert_eq!(decoded.output_tool_calls(), 0);
1571 }
1572
1573 #[test]
1574 fn empty_turn_classification_survives_a_serde_round_trip() {
1575 let live = vec![AssistantContent::text("")];
1584 assert!(is_empty_assistant_turn(&live));
1585
1586 let round: Vec<AssistantContent> =
1587 serde_json::from_str(&serde_json::to_string(&live).expect("serialize"))
1588 .expect("deserialize");
1589 assert!(
1590 is_empty_assistant_turn(&round),
1591 "restored turn must classify like the live one: {round:?}"
1592 );
1593
1594 for empty_spelling in [serde_json::json!({}), serde_json::Value::Null] {
1597 let migrated: Vec<AssistantContent> = serde_json::from_value(serde_json::json!([
1598 {"type": "text", "text": "", "additional_params": empty_spelling}
1599 ]))
1600 .expect("deserialize migrated");
1601 assert!(is_empty_assistant_turn(&migrated));
1602 }
1603
1604 let canonical_absent = vec![AssistantContent::Text(rig_core::message::Text {
1609 text: String::new(),
1610 additional_params: rig_core::message::AdditionalParams::try_from_value(
1611 serde_json::json!({}),
1612 )
1613 .expect("object params"),
1614 })];
1615 assert!(is_empty_assistant_turn(&canonical_absent));
1616 let restored: Vec<AssistantContent> =
1617 serde_json::from_value(serde_json::to_value(&canonical_absent).expect("serialize"))
1618 .expect("deserialize");
1619 assert!(is_empty_assistant_turn(&restored));
1620
1621 let annotated: Vec<AssistantContent> = serde_json::from_value(serde_json::json!([
1622 {"type": "text", "text": "", "additional_params": {"signature": "sig"}}
1623 ]))
1624 .expect("deserialize annotated");
1625 assert!(
1626 !is_empty_assistant_turn(&annotated),
1627 "an annotated empty block carries data: {annotated:?}"
1628 );
1629 }
1630
1631 #[test]
1632 fn prompt_response_deserializes_pre_monoid_null_usage_format() {
1633 let fixture = r#"{"output":"ok","usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"cached_input_tokens":0,"cache_creation_input_tokens":0,"tool_use_prompt_tokens":0,"reasoning_tokens":0},"completion_calls":[{"call_index":0,"usage":null},{"call_index":1,"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"cached_input_tokens":0,"cache_creation_input_tokens":0,"tool_use_prompt_tokens":0,"reasoning_tokens":0}}],"messages":[{"role":"user","content":[{"type":"text","text":"add things"}]}],"content":[{"type":"text","text":"ok"}]}"#;
1638
1639 let response: PromptResponse =
1640 serde_json::from_str(fixture).expect("old-format response should deserialize");
1641 assert_eq!(
1642 response.completion_calls(),
1643 &[
1644 CompletionCall::new(0, Usage::new()),
1645 CompletionCall::new(1, usage(3, 4))
1646 ]
1647 );
1648 let [AssistantContent::Text(text)] = response.content() else {
1651 panic!("expected one text block, got {:?}", response.content());
1652 };
1653 assert_eq!(text.text, "ok");
1654 }
1655
1656 #[test]
1657 fn the_type_key_is_the_tag_and_the_untagged_shape_does_not_load() {
1658 let tagged: Vec<AssistantContent> =
1665 serde_json::from_value(serde_json::json!([{"type": "text", "text": "ok"}]))
1666 .expect("deserialize");
1667 let [AssistantContent::Text(text)] = tagged.as_slice() else {
1668 panic!("expected one text block, got {tagged:?}");
1669 };
1670 assert_eq!(text.text, "ok");
1671 assert_eq!(text.additional_params, None, "the tag is not data");
1672
1673 serde_json::from_value::<Vec<AssistantContent>>(serde_json::json!([{"text": "ok"}]))
1674 .expect_err("the untagged shape must not deserialize");
1675 }
1676
1677 #[test]
1678 fn prompt_response_roundtrip_preserves_explicit_content() {
1679 let response = PromptResponse::new("visible text", Usage::new())
1683 .with_content(vec![AssistantContent::text("structured")]);
1684
1685 let value = serde_json::to_value(&response).expect("serialize prompt response");
1686 assert!(
1687 value.get("content").is_some(),
1688 "content is part of the serialized shape"
1689 );
1690
1691 let round: PromptResponse =
1692 serde_json::from_value(value).expect("deserialize prompt response");
1693 assert_eq!(round.output(), "visible text");
1694 let Some(AssistantContent::Text(text)) = round.content().first() else {
1699 panic!("expected text content, got {:?}", round.content().first());
1700 };
1701 assert_eq!(text.text, "structured");
1702 }
1703
1704 #[test]
1705 fn prompt_response_serialize_and_deserialize_agree_on_wire_shape() {
1706 let response = PromptResponse::new("hi", usage(1, 2))
1712 .with_completion_calls(vec![CompletionCall::new(0, usage(1, 2))]);
1713
1714 let from_response = serde_json::to_value(&response).expect("serialize response");
1715 assert!(from_response.get("content").is_some());
1716 assert!(from_response.get("completion_calls").is_some());
1717
1718 let round: PromptResponse =
1719 serde_json::from_value(from_response).expect("deserialize response");
1720 assert_eq!(round.output(), "hi");
1721 assert_eq!(round.usage(), usage(1, 2));
1722 assert_eq!(
1723 round.completion_calls(),
1724 &[CompletionCall::new(0, usage(1, 2))]
1725 );
1726
1727 let bare = serde_json::to_value(PromptResponse::new("hi", usage(1, 2)))
1731 .expect("serialize bare response");
1732 assert!(bare.get("completion_calls").is_none());
1733 let round: PromptResponse =
1734 serde_json::from_value(bare).expect("deserialize keyless response");
1735 assert!(round.completion_calls().is_empty());
1736 }
1737
1738 #[tokio::test]
1739 async fn prompt_response_records_completion_call_without_reported_usage() {
1740 let model = MockCompletionModel::new([MockTurn::text("ok")]);
1741 let agent = AgentBuilder::new(model).build();
1742
1743 let response = agent
1744 .prompt("say ok")
1745 .extended_details()
1746 .await
1747 .expect("prompt should succeed");
1748
1749 assert_eq!(response.output, "ok");
1750 assert_eq!(response.usage, Usage::new());
1751 assert_eq!(
1752 response.completion_calls(),
1753 &[CompletionCall::new(0, Usage::new())]
1754 );
1755 }
1756
1757 #[tokio::test]
1758 async fn typed_prompt_response_preserves_completion_calls() {
1759 let call_usage = Usage {
1760 input_tokens: 4,
1761 output_tokens: 6,
1762 total_tokens: 10,
1763 cached_input_tokens: 0,
1764 cache_creation_input_tokens: 0,
1765 tool_use_prompt_tokens: 0,
1766 reasoning_tokens: 0,
1767 };
1768 let model =
1769 MockCompletionModel::new([MockTurn::text(r#"{"value":"ok"}"#).with_usage(call_usage)]);
1770 let agent = AgentBuilder::new(model).build();
1771
1772 let response = agent
1773 .prompt_typed::<TypedAnswer>("return typed json")
1774 .extended_details()
1775 .await
1776 .expect("typed prompt should succeed");
1777
1778 assert_eq!(
1779 response.output,
1780 TypedAnswer {
1781 value: "ok".to_string()
1782 }
1783 );
1784 assert_eq!(response.usage, call_usage);
1785 assert_eq!(
1786 response.completion_calls(),
1787 &[CompletionCall::new(0, call_usage)]
1788 );
1789 }
1790
1791 fn validate_follow_up_tool_history(request: &CompletionRequest) {
1792 let history = request.chat_history.clone();
1793 assert_eq!(
1794 history.len(),
1795 3,
1796 "follow-up request should contain the prompt, assistant tool call, and user tool result: {history:?}"
1797 );
1798
1799 assert!(matches!(
1800 history.first(),
1801 Some(Message::User { content })
1802 if matches!(
1803 content.first(),
1804 Some(UserContent::Text(text)) if text.text == "do tool work"
1805 )
1806 ));
1807
1808 assert!(matches!(
1812 history.get(1),
1813 Some(Message::Assistant { content, .. })
1814 if matches!(
1815 content.first(),
1816 Some(AssistantContent::ToolCall(tool_call))
1817 if tool_call.id == "tool_call_1"
1818 && tool_call.provider.as_ref().is_some_and(
1819 |provider| provider.call_id == "call_1"
1820 )
1821 )
1822 ));
1823
1824 assert!(matches!(
1825 history.get(2),
1826 Some(Message::User { content })
1827 if matches!(
1828 content.first(),
1829 Some(UserContent::ToolResult(tool_result))
1830 if tool_result.call == "tool_call_1"
1831 && tool_result.provider.as_ref().is_some_and(
1832 |provider| provider.call_id == "call_1"
1833 )
1834 )
1835 ));
1836 }
1837
1838 fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1839 history.iter().any(|message| {
1840 matches!(
1841 message,
1842 Message::Assistant { content, .. }
1843 if content.iter().any(|item| matches!(
1844 item,
1845 AssistantContent::ToolCall(tool_call)
1846 if tool_call.function.name == tool_name
1847 ))
1848 )
1849 })
1850 }
1851
1852 fn assert_retry_transcript_ids_pair(assistant: &Message, results: &Message) {
1857 use std::collections::BTreeSet;
1858
1859 let Message::Assistant { content, .. } = assistant else {
1860 panic!("expected the assistant tool-call turn, got {assistant:?}");
1861 };
1862 let call_ids: Vec<&str> = content
1863 .iter()
1864 .filter_map(|item| match item {
1865 AssistantContent::ToolCall(tool_call) => Some(tool_call.id.as_str()),
1866 _ => None,
1867 })
1868 .collect();
1869 let Message::User { content } = results else {
1870 panic!("expected the user retry-result turn, got {results:?}");
1871 };
1872 let result_ids: Vec<&str> = content
1873 .iter()
1874 .filter_map(|item| match item {
1875 UserContent::ToolResult(result) => Some(result.call.as_str()),
1876 _ => None,
1877 })
1878 .collect();
1879 assert!(
1880 call_ids.iter().all(|id| !id.is_empty()),
1881 "every tool call carries a non-empty id: {call_ids:?}"
1882 );
1883 let unique_calls: BTreeSet<&str> = call_ids.iter().copied().collect();
1884 assert_eq!(
1885 unique_calls.len(),
1886 call_ids.len(),
1887 "tool-call ids must be unique: {call_ids:?}"
1888 );
1889 let unique_results: BTreeSet<&str> = result_ids.iter().copied().collect();
1890 assert_eq!(
1891 unique_results.len(),
1892 result_ids.len(),
1893 "retry-result ids must be unique: {result_ids:?}"
1894 );
1895 assert_eq!(
1896 unique_calls, unique_results,
1897 "retry results must answer exactly the turn's tool calls"
1898 );
1899 }
1900
1901 #[tokio::test]
1902 async fn unknown_tool_call_fails_before_non_streaming_second_request() {
1903 let model = MockCompletionModel::new([
1904 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2})),
1905 MockTurn::text("should not be requested"),
1906 ]);
1907 let recorded = model.clone();
1908 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1909
1910 let err = agent
1911 .prompt("use the tool")
1912 .add_hook(PanicOnUnknownToolHook)
1913 .max_turns(3)
1914 .await
1915 .expect_err("unknown model-emitted tool should fail");
1916
1917 match err {
1918 PromptError::UnknownToolCall {
1919 tool_name,
1920 available_tools,
1921 allowed_tools,
1922 chat_history,
1923 } => {
1924 assert_eq!(tool_name, "default_api");
1925 assert_eq!(available_tools, vec!["add".to_string()]);
1926 assert_eq!(allowed_tools, vec!["add".to_string()]);
1927 assert!(history_contains_tool_call(&chat_history, "default_api"));
1928 }
1929 other => panic!("expected UnknownToolCall, got {other:?}"),
1930 }
1931 assert_eq!(recorded.request_count(), 1);
1932 }
1933
1934 #[tokio::test]
1937 async fn tool_context_reaches_tool_through_agent_loop() {
1938 let model = MockCompletionModel::new([
1939 MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1940 MockTurn::text("done"),
1941 ]);
1942 let probe = MockContextProbeTool::default();
1943 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1944
1945 let mut context = ToolContext::new();
1946 context.insert(SessionId("abc-123".to_string()));
1947
1948 let out = agent
1949 .prompt("use the tool")
1950 .tool_context(context)
1951 .max_turns(3)
1952 .await
1953 .expect("run succeeds");
1954
1955 assert_eq!(out, "done");
1956 assert_eq!(probe.observed().as_deref(), Some("session:abc-123"));
1957 }
1958
1959 #[tokio::test]
1963 async fn tool_context_persists_across_multiple_rounds() {
1964 let model = MockCompletionModel::new([
1965 MockTurn::tool_call("c1", "context_probe", json!({})),
1966 MockTurn::tool_call("c2", "context_probe", json!({})),
1967 MockTurn::text("done"),
1968 ]);
1969 let probe = MockContextProbeTool::default();
1970 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1971
1972 let mut context = ToolContext::new();
1973 context.insert(SessionId("abc-123".to_string()));
1974
1975 let out = agent
1976 .prompt("use the tool twice")
1977 .tool_context(context)
1978 .max_turns(5)
1979 .await
1980 .expect("run succeeds");
1981
1982 assert_eq!(out, "done");
1983 assert_eq!(
1984 probe.observations(),
1985 vec!["session:abc-123".to_string(), "session:abc-123".to_string()],
1986 );
1987 }
1988
1989 #[tokio::test]
1992 async fn tool_runs_with_empty_context_when_none_supplied() {
1993 let model = MockCompletionModel::new([
1994 MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1995 MockTurn::text("done"),
1996 ]);
1997 let probe = MockContextProbeTool::default();
1998 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1999
2000 let out = agent
2001 .prompt("use the tool")
2002 .max_turns(3)
2003 .await
2004 .expect("run succeeds");
2005
2006 assert_eq!(out, "done");
2007 assert_eq!(probe.observed().as_deref(), Some("no-session"));
2009 }
2010
2011 #[tokio::test]
2013 async fn probe_direct_call_uses_context() {
2014 let probe = MockContextProbeTool::default();
2015 let out = probe
2016 .call(&mut ToolContext::new(), json!({}))
2017 .await
2018 .expect("call succeeds");
2019 assert_eq!(out, "no-session");
2020 assert_eq!(probe.observed().as_deref(), Some("no-session"));
2021 }
2022
2023 #[tokio::test]
2024 async fn invalid_tool_call_context_uses_completed_tool_call_provider_id() {
2025 let invalid_hook = RecordingInvalidToolCallHook::default();
2026 let model = MockCompletionModel::new([
2027 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2}))
2028 .with_call_id("provider_call_1"),
2029 MockTurn::text("should not be requested"),
2030 ]);
2031 let recorded = model.clone();
2032 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2033
2034 let err = agent
2035 .prompt("use the tool")
2036 .add_hook(invalid_hook.clone())
2037 .max_turns(3)
2038 .await
2039 .expect_err("invalid tool should fail");
2040
2041 assert!(matches!(err, PromptError::UnknownToolCall { .. }));
2042 assert_eq!(recorded.request_count(), 1);
2043 let contexts = invalid_hook.observed();
2044 assert_eq!(contexts.len(), 1);
2045 let context = &contexts[0];
2046 assert_eq!(context.tool_name, "default_api");
2047 assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
2048 assert_eq!(context.internal_call_id, None);
2049 assert!(!context.is_streaming);
2050 }
2051
2052 #[tokio::test]
2053 async fn disallowed_specific_tool_call_fails_before_non_streaming_second_request() {
2054 let model = MockCompletionModel::new([
2055 MockTurn::tool_call("tool_call_1", "subtract", json!({"x": 3, "y": 1})),
2056 MockTurn::text("should not be requested"),
2057 ]);
2058 let recorded = model.clone();
2059 let agent = AgentBuilder::new(model)
2060 .tool(MockAddTool)
2061 .tool(MockSubtractTool)
2062 .tool_choice(ToolChoice::Specific {
2063 function_names: vec!["add".to_string()],
2064 })
2065 .build();
2066
2067 let err = agent
2068 .prompt("use the allowed tool")
2069 .add_hook(PanicOnUnknownToolHook)
2070 .max_turns(3)
2071 .await
2072 .expect_err("disallowed model-emitted tool should fail");
2073
2074 match err {
2075 PromptError::UnknownToolCall {
2076 tool_name,
2077 available_tools,
2078 allowed_tools,
2079 chat_history,
2080 } => {
2081 assert_eq!(tool_name, "subtract");
2082 assert_eq!(
2083 available_tools,
2084 vec!["add".to_string(), "subtract".to_string()]
2085 );
2086 assert_eq!(allowed_tools, vec!["add".to_string()]);
2087 assert!(history_contains_tool_call(&chat_history, "subtract"));
2088 }
2089 other => panic!("expected UnknownToolCall, got {other:?}"),
2090 }
2091 assert_eq!(recorded.request_count(), 1);
2092 }
2093
2094 #[tokio::test]
2095 async fn tool_choice_none_rejects_non_streaming_tool_call() {
2096 let model = MockCompletionModel::new([
2097 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
2098 MockTurn::text("should not be requested"),
2099 ]);
2100 let recorded = model.clone();
2101 let agent = AgentBuilder::new(model)
2102 .tool(MockAddTool)
2103 .tool_choice(ToolChoice::None)
2104 .build();
2105
2106 let err = agent
2107 .prompt("do not use tools")
2108 .add_hook(PanicOnUnknownToolHook)
2109 .max_turns(3)
2110 .await
2111 .expect_err("ToolChoice::None should reject returned tool calls");
2112
2113 match err {
2114 PromptError::UnknownToolCall {
2115 tool_name,
2116 available_tools,
2117 allowed_tools,
2118 chat_history,
2119 } => {
2120 assert_eq!(tool_name, "add");
2121 assert_eq!(available_tools, vec!["add".to_string()]);
2122 assert!(allowed_tools.is_empty());
2123 assert!(history_contains_tool_call(&chat_history, "add"));
2124 }
2125 other => panic!("expected UnknownToolCall, got {other:?}"),
2126 }
2127 assert_eq!(recorded.request_count(), 1);
2128 }
2129
2130 #[tokio::test]
2131 async fn invalid_tool_call_hook_can_repair_non_streaming_tool_name() {
2132 let model = MockCompletionModel::new([
2133 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2134 MockTurn::text("done"),
2135 ]);
2136 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2137
2138 let response = agent
2139 .prompt("add")
2140 .add_hook(RepairDefaultApiHook)
2141 .max_turns(3)
2142 .extended_details()
2143 .await
2144 .expect("repaired tool call should execute");
2145
2146 assert_eq!(response.output, "done");
2147 let messages = response.messages.expect("messages should be present");
2148 assert!(history_contains_tool_call(&messages, "add"));
2149 assert!(!history_contains_tool_call(&messages, "default_api"));
2150 assert!(messages.iter().any(|message| {
2151 matches!(
2152 message,
2153 Message::User { content }
2154 if content.iter().any(|content| {
2155 matches!(
2156 content,
2157 UserContent::ToolResult(result)
2158 if result.content.iter().any(|content| {
2159 matches!(
2160 content,
2161 rig_core::message::ToolResultContent::Json { value }
2162 if value == &serde_json::json!(5)
2163 )
2164 })
2165 )
2166 })
2167 )
2168 }));
2169 }
2170
2171 #[tokio::test]
2172 async fn invalid_tool_call_hook_retry_adds_feedback_and_retries_non_streaming() {
2173 let model = MockCompletionModel::new([
2174 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2175 MockTurn::text("retried"),
2176 ]);
2177 let recorded = model.clone();
2178 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2179
2180 let response = agent
2181 .prompt("add")
2182 .add_hook(RetryDefaultApiHook)
2183 .max_invalid_tool_call_retries(1)
2184 .max_turns(3)
2185 .extended_details()
2186 .await
2187 .expect("retry should recover");
2188
2189 assert_eq!(response.output, "retried");
2190 assert_eq!(recorded.request_count(), 2);
2191 let messages = response.messages.expect("messages should be present");
2192 assert!(messages.iter().any(|message| {
2193 matches!(
2194 message,
2195 Message::User { content }
2196 if content.iter().any(|content| {
2197 matches!(
2198 content,
2199 UserContent::ToolResult(result)
2200 if result.content.iter().any(|content| {
2201 matches!(
2202 content,
2203 rig_core::message::ToolResultContent::Text(text)
2204 if text.text.contains("Use one of these tools instead")
2205 )
2206 })
2207 )
2208 })
2209 )
2210 }));
2211 }
2212
2213 #[tokio::test]
2214 async fn invalid_tool_call_hook_retries_mixed_non_streaming_turn_without_executing_valid_call()
2215 {
2216 let add_calls = Arc::new(AtomicU32::new(0));
2217 let valid_tool_call = ToolCall::from_wire(
2218 "tool_call_1",
2219 ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
2220 )
2221 .with_provider(ProviderCallId::new("call_1").expect("non-empty provider id"));
2222 let invalid_tool_call = ToolCall::from_wire(
2223 "tool_call_2",
2224 ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
2225 )
2226 .with_provider(ProviderCallId::new("call_2").expect("non-empty provider id"));
2227 let model = MockCompletionModel::new([
2228 MockTurn::from_contents([
2229 AssistantContent::ToolCall(valid_tool_call),
2230 AssistantContent::ToolCall(invalid_tool_call),
2231 ]),
2232 MockTurn::text("retried"),
2233 ]);
2234 let recorded = model.clone();
2235 let agent = AgentBuilder::new(model)
2236 .tool(CountingAddTool {
2237 calls: add_calls.clone(),
2238 })
2239 .build();
2240
2241 let response = agent
2242 .prompt("add")
2243 .add_hook(RetryDefaultApiHook)
2244 .max_invalid_tool_call_retries(1)
2245 .max_turns(3)
2246 .extended_details()
2247 .await
2248 .expect("retry should recover");
2249
2250 assert_eq!(response.output, "retried");
2251 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
2252 let requests = recorded.requests();
2253 assert_eq!(requests.len(), 2);
2254 let retry_history = requests[1].chat_history.clone();
2255 assert_eq!(retry_history.len(), 3);
2256 assert!(matches!(
2257 retry_history.get(1),
2258 Some(Message::Assistant { content, .. })
2259 if content.iter().any(|item| matches!(
2260 item,
2261 AssistantContent::ToolCall(tool_call)
2262 if tool_call.id == "tool_call_1"
2263 && tool_call.function.name == "add"
2264 ))
2265 && content.iter().any(|item| matches!(
2266 item,
2267 AssistantContent::ToolCall(tool_call)
2268 if tool_call.id == "tool_call_2"
2269 && tool_call.function.name == "default_api"
2270 ))
2271 ));
2272 assert!(matches!(
2273 retry_history.get(2),
2274 Some(Message::User { content })
2275 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
2276 && content.iter().any(|item| matches!(
2277 item,
2278 UserContent::ToolResult(result)
2279 if result.call == "tool_call_1"
2280 && result.provider.as_ref().is_some_and(
2281 |provider| provider.call_id == "call_1"
2282 )
2283 && result.content.iter().any(|content| matches!(
2284 content,
2285 rig_core::message::ToolResultContent::Text(text)
2286 if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
2287 ))
2288 ))
2289 && content.iter().any(|item| matches!(
2290 item,
2291 UserContent::ToolResult(result)
2292 if result.call == "tool_call_2"
2293 && result.provider.as_ref().is_some_and(
2294 |provider| provider.call_id == "call_2"
2295 )
2296 && result.content.iter().any(|content| matches!(
2297 content,
2298 rig_core::message::ToolResultContent::Text(text)
2299 if text.text.contains("Use one of these tools instead")
2300 ))
2301 ))
2302 ));
2303 assert_retry_transcript_ids_pair(
2304 retry_history.get(1).expect("assistant tool-call turn"),
2305 retry_history.get(2).expect("retry-result turn"),
2306 );
2307 }
2308
2309 #[tokio::test]
2310 async fn invalid_tool_call_hook_skips_mixed_non_streaming_turn_without_executing_valid_call() {
2311 let add_calls = Arc::new(AtomicU32::new(0));
2312 let valid_tool_call = ToolCall::from_wire(
2313 "tool_call_1",
2314 ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
2315 )
2316 .with_provider(ProviderCallId::new("call_1").expect("non-empty provider id"));
2317 let invalid_tool_call = ToolCall::from_wire(
2318 "tool_call_2",
2319 ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
2320 )
2321 .with_provider(ProviderCallId::new("call_2").expect("non-empty provider id"));
2322 let model = MockCompletionModel::new([
2323 MockTurn::from_contents([
2324 AssistantContent::ToolCall(valid_tool_call),
2325 AssistantContent::ToolCall(invalid_tool_call),
2326 ]),
2327 MockTurn::text("skipped"),
2328 ]);
2329 let agent = AgentBuilder::new(model)
2330 .tool(CountingAddTool {
2331 calls: add_calls.clone(),
2332 })
2333 .build();
2334
2335 let response = agent
2336 .prompt("add")
2337 .add_hook(SkipDefaultApiAndPanicOnToolCallHook)
2338 .max_turns(3)
2339 .extended_details()
2340 .await
2341 .expect("skip should recover without executing peer tools");
2342
2343 assert_eq!(response.output, "skipped");
2344 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
2345 let messages = response.messages.expect("messages should be present");
2346 assert!(history_contains_tool_call(&messages, "add"));
2347 assert!(history_contains_tool_call(&messages, "default_api"));
2348 assert!(matches!(
2349 messages.get(2),
2350 Some(Message::User { content })
2351 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
2352 && content.iter().any(|item| matches!(
2353 item,
2354 UserContent::ToolResult(result)
2355 if result.call == "tool_call_1"
2356 && result.provider.as_ref().is_some_and(
2357 |provider| provider.call_id == "call_1"
2358 )
2359 && result.content.iter().any(|content| matches!(
2360 content,
2361 rig_core::message::ToolResultContent::Text(text)
2362 if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
2363 ))
2364 ))
2365 && content.iter().any(|item| matches!(
2366 item,
2367 UserContent::ToolResult(result)
2368 if result.call == "tool_call_2"
2369 && result.provider.as_ref().is_some_and(
2370 |provider| provider.call_id == "call_2"
2371 )
2372 && result.content.iter().any(|content| matches!(
2373 content,
2374 rig_core::message::ToolResultContent::Text(text)
2375 if text.text == "default_api is not available"
2376 ))
2377 ))
2378 ));
2379 assert_retry_transcript_ids_pair(
2380 messages.get(1).expect("assistant tool-call turn"),
2381 messages.get(2).expect("skip-result turn"),
2382 );
2383 }
2384
2385 #[tokio::test]
2386 async fn invalid_tool_call_hook_retry_budget_exhaustion_fails() {
2387 let model = MockCompletionModel::new([
2388 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2389 MockTurn::text("should not be requested"),
2390 ]);
2391 let recorded = model.clone();
2392 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2393
2394 let err = agent
2395 .prompt("add")
2396 .add_hook(RetryDefaultApiHook)
2397 .max_invalid_tool_call_retries(0)
2398 .max_turns(3)
2399 .await
2400 .expect_err("retry without budget should fail");
2401
2402 match err {
2403 PromptError::UnknownToolCall {
2404 tool_name,
2405 chat_history,
2406 ..
2407 } => {
2408 assert_eq!(tool_name, "default_api");
2409 assert!(history_contains_tool_call(&chat_history, "default_api"));
2410 }
2411 other => panic!("expected UnknownToolCall, got {other:?}"),
2412 }
2413 assert_eq!(recorded.request_count(), 1);
2414 }
2415
2416 #[tokio::test]
2417 async fn invalid_tool_call_hook_can_skip_structured_non_streaming_call() {
2418 let model = MockCompletionModel::new([
2419 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2420 MockTurn::text("skipped"),
2421 ]);
2422 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2423
2424 let response = agent
2425 .prompt("add")
2426 .add_hook(SkipDefaultApiHook)
2427 .max_turns(3)
2428 .extended_details()
2429 .await
2430 .expect("skip should continue with synthetic tool result");
2431
2432 assert_eq!(response.output, "skipped");
2433 let messages = response.messages.expect("messages should be present");
2434 assert!(history_contains_tool_call(&messages, "default_api"));
2435 assert!(messages.iter().any(|message| {
2436 matches!(
2437 message,
2438 Message::User { content }
2439 if content.iter().any(|content| {
2440 matches!(
2441 content,
2442 UserContent::ToolResult(result)
2443 if result.content.iter().any(|content| {
2444 matches!(
2445 content,
2446 rig_core::message::ToolResultContent::Text(text)
2447 if text.text == "default_api is not available"
2448 )
2449 })
2450 )
2451 })
2452 )
2453 }));
2454 }
2455
2456 #[tokio::test]
2457 async fn skip_under_specific_tool_choice_returns_synthetic_feedback() {
2458 let model = MockCompletionModel::new([
2459 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2460 MockTurn::text("skipped"),
2461 ]);
2462 let agent = AgentBuilder::new(model)
2463 .tool(MockAddTool)
2464 .tool_choice(ToolChoice::Specific {
2465 function_names: vec!["add".to_string()],
2466 })
2467 .build();
2468
2469 let response = agent
2470 .prompt("add")
2471 .add_hook(SkipDefaultApiHook)
2472 .max_turns(3)
2473 .extended_details()
2474 .await
2475 .expect("skip should produce synthetic feedback under Specific");
2476
2477 assert_eq!(response.output, "skipped");
2478 let messages = response.messages.expect("messages should be present");
2479 assert!(history_contains_tool_call(&messages, "default_api"));
2480 assert!(messages.iter().any(|message| {
2481 matches!(
2482 message,
2483 Message::User { content }
2484 if content.iter().any(|content| {
2485 matches!(
2486 content,
2487 UserContent::ToolResult(result)
2488 if result.call == "tool_call_1"
2489 && result.content.iter().any(|content| {
2490 matches!(
2491 content,
2492 rig_core::message::ToolResultContent::Text(text)
2493 if text.text == "default_api is not available"
2494 )
2495 })
2496 )
2497 })
2498 )
2499 }));
2500 }
2501
2502 #[tokio::test]
2503 async fn repair_to_disallowed_specific_tool_fails() {
2504 let model = MockCompletionModel::new([
2505 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2506 MockTurn::text("should not be requested"),
2507 ]);
2508 let recorded = model.clone();
2509 let agent = AgentBuilder::new(model)
2510 .tool(MockAddTool)
2511 .tool(MockSubtractTool)
2512 .tool_choice(ToolChoice::Specific {
2513 function_names: vec!["add".to_string()],
2514 })
2515 .build();
2516
2517 let err = agent
2518 .prompt("add")
2519 .add_hook(RepairToSubtractHook)
2520 .max_turns(3)
2521 .await
2522 .expect_err("repair to a disallowed tool should fail");
2523
2524 match err {
2525 PromptError::UnknownToolCall { tool_name, .. } => {
2526 assert_eq!(tool_name, "subtract");
2527 }
2528 other => panic!("expected UnknownToolCall, got {other:?}"),
2529 }
2530 assert_eq!(recorded.request_count(), 1);
2531 }
2532
2533 #[tokio::test]
2534 async fn repair_under_tool_choice_none_fails() {
2535 let model = MockCompletionModel::new([
2536 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2537 MockTurn::text("should not be requested"),
2538 ]);
2539 let recorded = model.clone();
2540 let agent = AgentBuilder::new(model)
2541 .tool(MockAddTool)
2542 .tool_choice(ToolChoice::None)
2543 .build();
2544
2545 let err = agent
2546 .prompt("do not use tools")
2547 .add_hook(RepairDefaultApiHook)
2548 .max_turns(3)
2549 .await
2550 .expect_err("ToolChoice::None should reject repaired tool calls");
2551
2552 match err {
2553 PromptError::UnknownToolCall { tool_name, .. } => {
2554 assert_eq!(tool_name, "add");
2555 }
2556 other => panic!("expected UnknownToolCall, got {other:?}"),
2557 }
2558 assert_eq!(recorded.request_count(), 1);
2559 }
2560
2561 #[tokio::test]
2562 async fn skip_under_tool_choice_none_fails() {
2563 let model = MockCompletionModel::new([
2564 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2565 MockTurn::text("should not be requested"),
2566 ]);
2567 let recorded = model.clone();
2568 let agent = AgentBuilder::new(model)
2569 .tool(MockAddTool)
2570 .tool_choice(ToolChoice::None)
2571 .build();
2572
2573 let err = agent
2574 .prompt("do not use tools")
2575 .add_hook(SkipDefaultApiHook)
2576 .max_turns(3)
2577 .await
2578 .expect_err("ToolChoice::None should reject skipped tool calls");
2579
2580 match err {
2581 PromptError::UnknownToolCall { tool_name, .. } => {
2582 assert_eq!(tool_name, "default_api");
2583 }
2584 other => panic!("expected UnknownToolCall, got {other:?}"),
2585 }
2586 assert_eq!(recorded.request_count(), 1);
2587 }
2588
2589 #[tokio::test]
2590 async fn typed_prompt_default_invalid_tool_call_fails_fast() {
2591 let model = MockCompletionModel::new([
2592 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2593 MockTurn::text(r#"{"value":"should not be requested"}"#),
2594 ]);
2595 let recorded = model.clone();
2596 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2597
2598 let err = agent
2599 .prompt_typed::<TypedAnswer>("return typed json")
2600 .add_hook(PanicOnUnknownToolHook)
2601 .max_turns(3)
2602 .await
2603 .expect_err("typed prompt should preserve fail-fast default");
2604
2605 match err {
2606 StructuredOutputError::PromptError(err) => match *err {
2607 PromptError::UnknownToolCall { tool_name, .. } => {
2608 assert_eq!(tool_name, "default_api");
2609 }
2610 other => panic!("expected UnknownToolCall, got {other:?}"),
2611 },
2612 other => panic!("expected prompt error, got {other:?}"),
2613 }
2614 assert_eq!(recorded.request_count(), 1);
2615 }
2616
2617 #[tokio::test]
2618 async fn typed_prompt_invalid_tool_call_hook_can_repair_tool_name() {
2619 let model = MockCompletionModel::new([
2620 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2621 MockTurn::text(r#"{"value":"repaired"}"#),
2622 ]);
2623 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2624
2625 let response = agent
2626 .prompt_typed::<TypedAnswer>("return typed json")
2627 .add_hook(RepairDefaultApiHook)
2628 .max_turns(3)
2629 .await
2630 .expect("typed prompt should repair invalid tool call");
2631
2632 assert_eq!(
2633 response,
2634 TypedAnswer {
2635 value: "repaired".to_string()
2636 }
2637 );
2638 }
2639
2640 #[tokio::test]
2641 async fn typed_prompt_invalid_tool_call_hook_can_retry_and_parse_response() {
2642 let model = MockCompletionModel::new([
2643 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2644 MockTurn::text(r#"{"value":"retried"}"#),
2645 ]);
2646 let recorded = model.clone();
2647 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2648
2649 let response = agent
2650 .prompt_typed::<TypedAnswer>("return typed json")
2651 .add_hook(RetryDefaultApiHook)
2652 .max_invalid_tool_call_retries(1)
2653 .max_turns(3)
2654 .await
2655 .expect("typed prompt should retry invalid tool call");
2656
2657 assert_eq!(
2658 response,
2659 TypedAnswer {
2660 value: "retried".to_string()
2661 }
2662 );
2663 assert_eq!(recorded.request_count(), 2);
2664 }
2665
2666 #[tokio::test]
2667 async fn typed_prompt_invalid_tool_call_retry_budget_exhaustion_fails() {
2668 let model = MockCompletionModel::new([
2669 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2670 MockTurn::text(r#"{"value":"should not be requested"}"#),
2671 ]);
2672 let recorded = model.clone();
2673 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2674
2675 let err = agent
2676 .prompt_typed::<TypedAnswer>("return typed json")
2677 .add_hook(RetryDefaultApiHook)
2678 .max_invalid_tool_call_retries(0)
2679 .max_turns(3)
2680 .await
2681 .expect_err("typed prompt should fail when retry budget is exhausted");
2682
2683 match err {
2684 StructuredOutputError::PromptError(err) => match *err {
2685 PromptError::UnknownToolCall { tool_name, .. } => {
2686 assert_eq!(tool_name, "default_api");
2687 }
2688 other => panic!("expected UnknownToolCall, got {other:?}"),
2689 },
2690 other => panic!("expected prompt error, got {other:?}"),
2691 }
2692 assert_eq!(recorded.request_count(), 1);
2693 }
2694
2695 #[tokio::test]
2696 async fn invalid_specific_tool_choice_fails_before_non_streaming_provider_request() {
2697 let model = MockCompletionModel::text("should not be requested");
2698 let recorded = model.clone();
2699 let agent = AgentBuilder::new(model)
2700 .tool(MockAddTool)
2701 .tool_choice(ToolChoice::Specific {
2702 function_names: vec!["missing".to_string()],
2703 })
2704 .build();
2705
2706 let err = agent
2707 .prompt("use the missing tool")
2708 .await
2709 .expect_err("invalid ToolChoice::Specific should fail before provider request");
2710
2711 match err {
2712 PromptError::CompletionError(CompletionError::RequestError(err)) => {
2713 let msg = err.to_string();
2714 assert!(msg.contains("missing"), "got: {msg}");
2715 assert!(msg.contains("add"), "got: {msg}");
2716 }
2717 other => panic!("expected CompletionError::RequestError, got {other:?}"),
2718 }
2719 assert_eq!(recorded.request_count(), 0);
2720 }
2721
2722 #[tokio::test]
2723 async fn allowed_specific_tool_call_executes_normally() {
2724 let model = MockCompletionModel::new([
2725 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
2726 MockTurn::text("done"),
2727 ]);
2728 let recorded = model.clone();
2729 let agent = AgentBuilder::new(model)
2730 .tool(MockAddTool)
2731 .tool_choice(ToolChoice::Specific {
2732 function_names: vec!["add".to_string()],
2733 })
2734 .build();
2735
2736 let response = agent
2737 .prompt("use the allowed tool")
2738 .max_turns(3)
2739 .await
2740 .expect("allowed specific tool should execute");
2741
2742 assert_eq!(response, "done");
2743 assert_eq!(recorded.request_count(), 2);
2744 }
2745
2746 #[tokio::test]
2747 async fn prompt_request_stops_cleanly_on_empty_terminal_turn() {
2748 let first_call_usage = Usage {
2749 input_tokens: 1,
2750 output_tokens: 1,
2751 total_tokens: 2,
2752 cached_input_tokens: 0,
2753 cache_creation_input_tokens: 0,
2754 tool_use_prompt_tokens: 0,
2755 reasoning_tokens: 0,
2756 };
2757 let second_call_usage = Usage {
2758 input_tokens: 1,
2759 output_tokens: 1,
2760 total_tokens: 2,
2761 cached_input_tokens: 0,
2762 cache_creation_input_tokens: 0,
2763 tool_use_prompt_tokens: 0,
2764 reasoning_tokens: 0,
2765 };
2766 let model = MockCompletionModel::new([
2767 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2}))
2768 .with_call_id("call_1")
2769 .with_usage(first_call_usage),
2770 MockTurn::text("").with_usage(second_call_usage),
2771 ]);
2772 let agent = AgentBuilder::new(model.clone()).tool(MockAddTool).build();
2773
2774 let response = agent
2775 .prompt("do tool work")
2776 .max_turns(3)
2777 .extended_details()
2778 .await
2779 .expect("empty terminal turn should not error");
2780
2781 assert!(response.output.is_empty());
2782 assert_eq!(
2783 response.usage,
2784 Usage {
2785 input_tokens: 2,
2786 output_tokens: 2,
2787 total_tokens: 4,
2788 cached_input_tokens: 0,
2789 cache_creation_input_tokens: 0,
2790 tool_use_prompt_tokens: 0,
2791 reasoning_tokens: 0,
2792 }
2793 );
2794 assert_eq!(
2795 response.completion_calls(),
2796 &[
2797 CompletionCall::new(0, first_call_usage),
2798 CompletionCall::new(1, second_call_usage)
2799 ]
2800 );
2801
2802 let history = response
2803 .messages
2804 .expect("extended response should include history");
2805 assert_eq!(history.len(), 3);
2806 assert!(matches!(
2807 history.first(),
2808 Some(Message::User { content })
2809 if matches!(
2810 content.first(),
2811 Some(UserContent::Text(text)) if text.text == "do tool work"
2812 )
2813 ));
2814 assert!(history.iter().any(|message| matches!(
2815 message,
2816 Message::Assistant { content, .. }
2817 if matches!(
2818 content.first(),
2819 Some(AssistantContent::ToolCall(tool_call))
2820 if tool_call.id == "tool_call_1"
2821 && tool_call.provider.as_ref().is_some_and(
2822 |provider| provider.call_id == "call_1"
2823 )
2824 )
2825 )));
2826 assert!(history.iter().any(|message| matches!(
2827 message,
2828 Message::User { content }
2829 if matches!(
2830 content.first(),
2831 Some(UserContent::ToolResult(tool_result))
2832 if tool_result.call == "tool_call_1"
2833 && tool_result.provider.as_ref().is_some_and(
2834 |provider| provider.call_id == "call_1"
2835 )
2836 )
2837 )));
2838 assert!(!history.iter().any(|message| matches!(
2839 message,
2840 Message::Assistant { content, .. }
2841 if content.iter().any(|item| matches!(
2842 item,
2843 AssistantContent::Text(text) if text.text.is_empty()
2844 ))
2845 )));
2846 let requests = model.requests();
2847 assert_eq!(requests.len(), 2);
2848 validate_follow_up_tool_history(&requests[1]);
2849 }
2850
2851 #[tokio::test]
2852 async fn prompt_request_concatenates_text_blocks_without_inserted_newlines() {
2853 let model = MockCompletionModel::new([MockTurn::from_contents([
2854 AssistantContent::Text(Text::new("According to the document, ")),
2855 AssistantContent::Text(Text::new("the grass is green")),
2856 AssistantContent::Text(Text::new(" and the sky is blue.")),
2857 ])]);
2858 let agent = AgentBuilder::new(model).build();
2859
2860 let response = agent
2861 .prompt("answer with cited spans")
2862 .await
2863 .expect("prompt should succeed");
2864
2865 assert_eq!(
2866 response,
2867 "According to the document, the grass is green and the sky is blue."
2868 );
2869 }
2870
2871 #[tokio::test]
2872 async fn prompt_request_preserves_metadata_only_text_turn_in_history() {
2873 let metadata = rig_core::message::AdditionalParams::try_from_value(json!({
2874 "citations": [{
2875 "type": "web_search_result_location",
2876 "cited_text": "Claude Shannon was born in 1916.",
2877 "url": "https://example.com/shannon",
2878 "title": null,
2879 "encrypted_index": "encrypted-reference"
2880 }]
2881 }))
2882 .expect("object params")
2883 .expect("params carry data");
2884 let model =
2885 MockCompletionModel::new([MockTurn::from_content(AssistantContent::Text(Text {
2886 text: String::new(),
2887 additional_params: Some(metadata.clone()),
2888 }))]);
2889 let agent = AgentBuilder::new(model).build();
2890
2891 let response = agent
2892 .prompt("answer with cited metadata")
2893 .extended_details()
2894 .await
2895 .expect("metadata-only text turn should succeed");
2896
2897 assert!(response.output.is_empty());
2898 let history = response
2899 .messages
2900 .expect("extended response should include history");
2901 assert!(history.iter().any(|message| matches!(
2902 message,
2903 Message::Assistant { content, .. }
2904 if matches!(
2905 content.first(),
2906 Some(AssistantContent::Text(text))
2907 if text.text.is_empty()
2908 && text.additional_params.as_ref() == Some(&metadata)
2909 )
2910 )));
2911 }
2912
2913 use rig_core::memory::{ConversationMemory, InMemoryConversationMemory};
2916
2917 #[tokio::test]
2918 async fn memory_loads_into_request_history() {
2919 let memory = InMemoryConversationMemory::new();
2920 memory
2921 .append(
2922 "thread-1",
2923 vec![Message::user("hello"), Message::assistant("hi there")],
2924 )
2925 .await
2926 .unwrap();
2927
2928 let model = MockCompletionModel::text("ack");
2929 let recorded = model.clone();
2930
2931 let agent = AgentBuilder::new(model).memory(memory).build();
2932 let _ = agent
2933 .prompt("ping")
2934 .conversation("thread-1")
2935 .await
2936 .expect("prompt should succeed");
2937
2938 let received = recorded.requests()[0].chat_history.clone();
2939 assert_eq!(
2940 received.len(),
2941 3,
2942 "loaded memory (2) + current prompt should appear in request: {received:?}"
2943 );
2944 }
2945
2946 #[tokio::test]
2947 async fn memory_appends_full_turn_after_success() {
2948 let memory = InMemoryConversationMemory::new();
2949 let model = MockCompletionModel::text("ack");
2950 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2951
2952 let _ = agent
2953 .prompt("hello")
2954 .conversation("t1")
2955 .await
2956 .expect("prompt should succeed");
2957
2958 let stored = memory.load("t1").await.unwrap();
2959 assert_eq!(stored.len(), 2, "user prompt + assistant response saved");
2960 }
2961
2962 #[tokio::test]
2963 async fn explicit_with_history_overrides_memory() {
2964 let memory = CountingMemory::default();
2965 memory
2966 .inner()
2967 .append("t1", vec![Message::user("from-memory")])
2968 .await
2969 .unwrap();
2970
2971 let model = MockCompletionModel::text("ack");
2972 let recorded = model.clone();
2973
2974 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2975 let _ = agent
2976 .prompt("hello")
2977 .conversation("t1")
2978 .history(vec![Message::user("from-caller")])
2979 .await
2980 .expect("prompt should succeed");
2981
2982 assert_eq!(memory.load_count(), 0, "load skipped");
2983 let appends = memory.append_count();
2984 assert_eq!(appends, 0, "append skipped");
2985
2986 let received = recorded.requests()[0].chat_history.clone();
2987 assert_eq!(received.len(), 2, "caller history (1) + current prompt");
2988 assert!(matches!(
2989 received.first(),
2990 Some(Message::User { content })
2991 if matches!(content.first(), Some(UserContent::Text(t)) if t.text == "from-caller")
2992 ));
2993 }
2994
2995 #[tokio::test]
2996 async fn memory_unchanged_on_provider_error() {
2997 let memory = InMemoryConversationMemory::new();
2998 let model = MockCompletionModel::new([MockTurn::error("boom")]);
2999
3000 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
3001 let result = agent.prompt("hello").conversation("t1").await;
3002 assert!(result.is_err());
3003
3004 let stored = memory.load("t1").await.unwrap();
3005 assert!(stored.is_empty(), "no append on error");
3006 }
3007
3008 #[tokio::test]
3009 async fn multi_step_tool_run_appends_committed_turn_exactly_once() {
3010 let memory = CountingMemory::default();
3014 let model = MockCompletionModel::new([
3015 MockTurn::tool_call("call-1", "add", json!({"x": 2, "y": 3})),
3016 MockTurn::text("sum is 5"),
3017 ]);
3018
3019 let agent = AgentBuilder::new(model)
3020 .memory(memory.clone())
3021 .tool(MockAddTool)
3022 .default_max_turns(2)
3023 .build();
3024
3025 let _ = agent
3026 .prompt("add 2 and 3")
3027 .conversation("t1")
3028 .await
3029 .expect("multi-step run should succeed");
3030
3031 assert_eq!(
3032 memory.append_count(),
3033 1,
3034 "one append for the whole run, not one per model call"
3035 );
3036
3037 let stored = memory.load("t1").await.unwrap();
3038 assert_eq!(
3040 stored.len(),
3041 4,
3042 "the full committed turn is persisted once: {stored:?}"
3043 );
3044 assert!(
3045 matches!(
3046 stored.last(),
3047 Some(Message::Assistant { content, .. })
3048 if content
3049 .iter()
3050 .any(|item| matches!(item, AssistantContent::Text(t) if t.text == "sum is 5"))
3051 ),
3052 "final assistant text is persisted: {stored:?}"
3053 );
3054 }
3055
3056 #[tokio::test]
3057 async fn append_persists_only_newly_committed_messages() {
3058 let memory = CountingMemory::default();
3063 memory
3064 .inner()
3065 .append(
3066 "t1",
3067 vec![Message::user("old-q"), Message::assistant("old-a")],
3068 )
3069 .await
3070 .unwrap();
3071
3072 let model = MockCompletionModel::text("new-a");
3073 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
3074
3075 let _ = agent
3076 .prompt("new-q")
3077 .conversation("t1")
3078 .await
3079 .expect("prompt should succeed");
3080
3081 assert_eq!(memory.append_count(), 1, "one append for the run");
3082
3083 let stored = memory.load("t1").await.unwrap();
3084 assert_eq!(
3087 stored.len(),
3088 4,
3089 "only the new turn is appended, loaded history is not duplicated: {stored:?}"
3090 );
3091 assert!(
3092 matches!(
3093 stored.first(),
3094 Some(Message::User { content })
3095 if matches!(content.first(), Some(UserContent::Text(t)) if t.text == "old-q")
3096 ),
3097 "loaded history is preserved once at the front: {stored:?}"
3098 );
3099 }
3100
3101 #[tokio::test]
3102 async fn hook_stopped_run_does_not_append() {
3103 struct StopOnCompletion;
3105 impl AgentHook for StopOnCompletion {
3106 async fn on_completion_call(
3107 &self,
3108 _ctx: &HookContext,
3109 _event: crate::agent::CompletionCallEvent<'_>,
3110 ) -> crate::agent::CompletionCallAction {
3111 crate::agent::CompletionCallAction::stop("stop")
3112 }
3113 }
3114
3115 let memory = CountingMemory::default();
3116 let model = MockCompletionModel::text("unreached");
3117 let agent = AgentBuilder::new(model)
3118 .memory(memory.clone())
3119 .add_hook(StopOnCompletion)
3120 .build();
3121
3122 let result = agent.prompt("hello").conversation("t1").await;
3123 assert!(result.is_err(), "a stop hook terminates the run");
3124
3125 assert_eq!(memory.append_count(), 0, "stopped runs do not append");
3126 let stored = memory.load("t1").await.unwrap();
3127 assert!(stored.is_empty(), "nothing persisted on stop: {stored:?}");
3128 }
3129
3130 #[tokio::test]
3131 async fn committed_transcript_roles_form_a_valid_sequence() {
3132 let memory = CountingMemory::default();
3137 let model = MockCompletionModel::new([
3138 MockTurn::tool_call("call-1", "add", json!({"x": 1, "y": 1})),
3139 MockTurn::text("done"),
3140 ]);
3141
3142 let agent = AgentBuilder::new(model)
3143 .memory(memory.clone())
3144 .tool(MockAddTool)
3145 .default_max_turns(2)
3146 .build();
3147
3148 let _ = agent
3149 .prompt("go")
3150 .conversation("t1")
3151 .await
3152 .expect("run should succeed");
3153
3154 let stored = memory.load("t1").await.unwrap();
3155
3156 assert!(
3157 matches!(stored.first(), Some(Message::User { .. })),
3158 "committed transcript begins with a user message: {stored:?}"
3159 );
3160 assert!(
3161 !stored
3162 .windows(2)
3163 .any(|pair| matches!(pair, [Message::Assistant { .. }, Message::Assistant { .. }])),
3164 "no two assistant messages are committed back to back: {stored:?}"
3165 );
3166 for (index, message) in stored.iter().enumerate() {
3169 let has_tool_call = matches!(
3170 message,
3171 Message::Assistant { content, .. }
3172 if content.iter().any(|item| matches!(item, AssistantContent::ToolCall(_)))
3173 );
3174 if has_tool_call {
3175 assert!(
3176 matches!(stored.get(index + 1), Some(Message::User { content })
3177 if content
3178 .iter()
3179 .any(|item| matches!(item, UserContent::ToolResult(_)))),
3180 "assistant tool call at {index} is followed by a user tool result: {stored:?}"
3181 );
3182 }
3183 }
3184 }
3185
3186 #[tokio::test]
3187 async fn missing_conversation_id_behaves_as_no_memory() {
3188 let memory = CountingMemory::default();
3189 let model = MockCompletionModel::text("ack");
3190 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
3191
3192 let _ = agent.prompt("hello").await.expect("prompt should succeed");
3193
3194 assert_eq!(memory.load_count(), 0);
3195 assert_eq!(memory.append_count(), 0);
3196 }
3197
3198 #[tokio::test]
3199 async fn default_conversation_id_is_used_when_none_per_request() {
3200 let memory = InMemoryConversationMemory::new();
3201 let model = MockCompletionModel::text("ack");
3202 let agent = AgentBuilder::new(model)
3203 .memory(memory.clone())
3204 .conversation("default-thread")
3205 .build();
3206
3207 let _ = agent.prompt("hello").await.expect("prompt should succeed");
3208 let stored = memory.load("default-thread").await.unwrap();
3209 assert_eq!(stored.len(), 2);
3210 }
3211
3212 #[tokio::test]
3213 async fn with_filter_truncates_loaded_history() {
3214 let memory = InMemoryConversationMemory::new()
3215 .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
3216 memory
3217 .append(
3218 "t1",
3219 vec![
3220 Message::user("1"),
3221 Message::assistant("2"),
3222 Message::user("3"),
3223 Message::assistant("4"),
3224 ],
3225 )
3226 .await
3227 .unwrap();
3228
3229 let model = MockCompletionModel::text("ack");
3230 let recorded = model.clone();
3231 let agent = AgentBuilder::new(model).memory(memory).build();
3232
3233 let _ = agent
3234 .prompt("ping")
3235 .conversation("t1")
3236 .await
3237 .expect("prompt should succeed");
3238
3239 let received = recorded.requests()[0].chat_history.clone();
3240 assert_eq!(
3241 received.len(),
3242 3,
3243 "window-truncated history (2) + current prompt"
3244 );
3245 }
3246
3247 #[tokio::test]
3248 async fn without_memory_disables_for_request() {
3249 let memory = CountingMemory::default();
3250 let model = MockCompletionModel::text("ack");
3251 let agent = AgentBuilder::new(model)
3252 .memory(memory.clone())
3253 .conversation("t1")
3254 .build();
3255
3256 let _ = agent
3257 .prompt("hello")
3258 .without_memory()
3259 .await
3260 .expect("prompt should succeed");
3261
3262 assert_eq!(memory.load_count(), 0);
3263 assert_eq!(memory.append_count(), 0);
3264 }
3265
3266 #[tokio::test]
3267 async fn memory_load_error_surfaces_as_prompt_error() {
3268 let model = MockCompletionModel::text("ack");
3269 let agent = AgentBuilder::new(model)
3270 .memory(FailingMemory::default())
3271 .build();
3272 let result = agent.prompt("hello").conversation("t1").await;
3273
3274 match result {
3275 Err(PromptError::MemoryError(err)) => {
3276 let msg = err.to_string();
3277 assert!(msg.contains("load boom"), "got: {msg}");
3278 }
3279 other => panic!("expected PromptError::MemoryError, got {other:?}"),
3280 }
3281 }
3282
3283 #[tokio::test]
3284 async fn memory_append_error_does_not_drop_response() {
3285 let model = MockCompletionModel::text("ack");
3286 let agent = AgentBuilder::new(model)
3287 .memory(AppendFailingMemory::default())
3288 .build();
3289 let response: String = agent
3290 .prompt("hello")
3291 .conversation("t1")
3292 .await
3293 .expect("append failure must not block successful completion");
3294
3295 assert!(!response.is_empty());
3296 }
3297
3298 #[test]
3301 fn completion_call_without_identity_fields_still_deserializes() {
3302 let call: CompletionCall = serde_json::from_str(
3303 r#"{"call_index": 3, "usage": {"input_tokens": 1, "output_tokens": 2,
3304 "total_tokens": 3, "cached_input_tokens": 0,
3305 "cache_creation_input_tokens": 0, "reasoning_tokens": 0}}"#,
3306 )
3307 .expect("pre-identity CompletionCall JSON should load");
3308 assert_eq!(call.call_index, 3);
3309 assert_eq!(call.identity(), ResponseIdentity::default());
3310 }
3311
3312 #[test]
3314 fn completion_call_identity_round_trips() {
3315 let call = CompletionCall::new(0, crate::completion::Usage::new()).with_identity(
3316 ResponseIdentity {
3317 message_id: Some("msg_1".into()),
3318 response_id: Some("resp_1".into()),
3319 provider_request_id: Some("req_1".into()),
3320 },
3321 );
3322 let json = serde_json::to_string(&call).expect("serialize");
3323 let restored: CompletionCall = serde_json::from_str(&json).expect("deserialize");
3324 assert_eq!(restored, call);
3325 }
3326}