1pub mod streaming;
2
3use super::{Agent, hook::AgentHook, run::OutputMode, runner::AgentRunner};
4use crate::{
5 OneOrMany,
6 completion::{CompletionModel, Message, PromptError, Usage},
7 message::{AssistantContent, ToolResultContent, UserContent},
8 tool::ToolCallExtensions,
9 wasm_compat::{WasmBoxedFuture, WasmCompatSend},
10};
11use serde::{Deserialize, Serialize};
12use std::{future::IntoFuture, marker::PhantomData};
13
14macro_rules! forward_prompt_setters {
23 ($recv:ident) => {
24 pub fn tool_extensions(mut self, extensions: ToolCallExtensions) -> Self {
31 self.$recv = self.$recv.tool_extensions(extensions);
32 self
33 }
34
35 pub fn history<H, Item>(mut self, history: H) -> Self
37 where
38 H: IntoIterator<Item = Item>,
39 Item: Into<Message>,
40 {
41 self.$recv = self.$recv.history(history);
42 self
43 }
44
45 pub fn conversation(mut self, id: impl Into<String>) -> Self {
50 self.$recv = self.$recv.conversation(id);
51 self
52 }
53
54 pub fn without_memory(mut self) -> Self {
58 self.$recv = self.$recv.without_memory();
59 self
60 }
61
62 pub fn max_invalid_tool_call_retries(mut self, retries: usize) -> Self {
66 self.$recv = self.$recv.max_invalid_tool_call_retries(retries);
67 self
68 }
69 };
70}
71pub(crate) use forward_prompt_setters;
72
73macro_rules! forward_tool_concurrency {
78 ($recv:ident) => {
79 pub fn tool_concurrency(mut self, concurrency: usize) -> Self {
86 self.$recv = self.$recv.tool_concurrency(concurrency);
87 self
88 }
89 };
90}
91
92pub trait PromptType {}
93pub struct Standard;
94pub struct Extended;
95
96impl PromptType for Standard {}
97impl PromptType for Extended {}
98
99pub struct PromptRequest<S, M>
107where
108 S: PromptType,
109 M: CompletionModel,
110{
111 pub(crate) runner: AgentRunner<M>,
113 state: PhantomData<S>,
115}
116
117impl<M> PromptRequest<Standard, M>
118where
119 M: CompletionModel,
120{
121 pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
124 PromptRequest {
125 runner: AgentRunner::from_agent(agent, prompt),
126 state: PhantomData,
127 }
128 }
129}
130
131impl<S, M> PromptRequest<S, M>
132where
133 S: PromptType,
134 M: CompletionModel,
135{
136 pub fn extended_details(self) -> PromptRequest<Extended, M> {
143 PromptRequest {
144 runner: self.runner,
145 state: PhantomData,
146 }
147 }
148
149 pub fn max_turns(mut self, max_turns: usize) -> Self {
154 self.runner = self.runner.max_turns(max_turns);
155 self
156 }
157
158 pub fn add_hook<H>(mut self, hook: H) -> Self
165 where
166 H: AgentHook<M> + 'static,
167 {
168 self.runner = self.runner.add_hook(hook);
169 self
170 }
171
172 forward_prompt_setters!(runner);
173 forward_tool_concurrency!(runner);
174}
175
176impl<M> IntoFuture for PromptRequest<Standard, M>
180where
181 M: CompletionModel + 'static,
182{
183 type Output = Result<String, PromptError>;
184 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
185
186 fn into_future(self) -> Self::IntoFuture {
187 Box::pin(self.send())
188 }
189}
190
191impl<M> IntoFuture for PromptRequest<Extended, M>
192where
193 M: CompletionModel + 'static,
194{
195 type Output = Result<PromptResponse, PromptError>;
196 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
197
198 fn into_future(self) -> Self::IntoFuture {
199 Box::pin(self.send())
200 }
201}
202
203impl<M> PromptRequest<Standard, M>
204where
205 M: CompletionModel,
206{
207 async fn send(self) -> Result<String, PromptError> {
208 self.extended_details().send().await.map(|resp| resp.output)
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[non_exhaustive]
215pub struct CompletionCall {
216 pub call_index: usize,
218 #[serde(default, deserialize_with = "usage_null_as_default")]
224 pub usage: Usage,
225}
226
227impl CompletionCall {
228 pub fn new(call_index: usize, usage: Usage) -> Self {
230 Self { call_index, usage }
231 }
232}
233
234fn usage_null_as_default<'de, D>(deserializer: D) -> Result<Usage, D::Error>
241where
242 D: serde::Deserializer<'de>,
243{
244 Ok(Option::<Usage>::deserialize(deserializer)?.unwrap_or_default())
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
257#[serde(from = "PromptResponseRepr", into = "PromptResponseRepr")]
264#[non_exhaustive]
265pub struct PromptResponse {
266 pub output: String,
268 pub usage: Usage,
270 pub completion_calls: Vec<CompletionCall>,
277 pub messages: Option<Vec<Message>>,
280 pub content: OneOrMany<AssistantContent>,
285}
286
287#[derive(Serialize, Deserialize)]
295struct PromptResponseRepr {
296 output: String,
297 usage: Usage,
298 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 completion_calls: Vec<CompletionCall>,
300 messages: Option<Vec<Message>>,
301 #[serde(default)]
302 content: Option<OneOrMany<AssistantContent>>,
303}
304
305impl From<PromptResponseRepr> for PromptResponse {
306 fn from(repr: PromptResponseRepr) -> Self {
307 let content = repr
308 .content
309 .unwrap_or_else(|| OneOrMany::one(AssistantContent::text(repr.output.clone())));
310 Self {
311 output: repr.output,
312 usage: repr.usage,
313 completion_calls: repr.completion_calls,
314 messages: repr.messages,
315 content,
316 }
317 }
318}
319
320impl From<PromptResponse> for PromptResponseRepr {
321 fn from(response: PromptResponse) -> Self {
322 Self {
323 output: response.output,
324 usage: response.usage,
325 completion_calls: response.completion_calls,
326 messages: response.messages,
327 content: Some(response.content),
328 }
329 }
330}
331
332impl std::fmt::Display for PromptResponse {
333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334 self.output.fmt(f)
335 }
336}
337
338impl PromptResponse {
339 pub fn new(output: impl Into<String>, usage: Usage) -> Self {
340 let output = output.into();
341 Self {
342 content: OneOrMany::one(AssistantContent::text(output.clone())),
343 output,
344 usage,
345 completion_calls: Vec::new(),
346 messages: None,
347 }
348 }
349
350 pub fn empty() -> Self {
352 Self::new(String::new(), Usage::new())
353 }
354
355 pub fn with_messages(mut self, messages: Vec<Message>) -> Self {
356 self.messages = Some(messages);
357 self
358 }
359
360 pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
362 self.completion_calls = completion_calls;
363 self
364 }
365
366 pub fn with_content(mut self, content: OneOrMany<AssistantContent>) -> Self {
368 self.content = content;
369 self
370 }
371
372 pub fn output(&self) -> &str {
374 &self.output
375 }
376
377 pub fn usage(&self) -> Usage {
379 self.usage
380 }
381
382 pub fn messages(&self) -> Option<&[Message]> {
384 self.messages.as_deref()
385 }
386
387 pub fn content(&self) -> &OneOrMany<AssistantContent> {
389 &self.content
390 }
391
392 pub fn completion_calls(&self) -> &[CompletionCall] {
397 &self.completion_calls
398 }
399
400 pub fn requests(&self) -> usize {
402 self.completion_calls.len()
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[non_exhaustive]
408pub struct TypedPromptResponse<T> {
409 pub output: T,
410 pub usage: Usage,
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
418 pub completion_calls: Vec<CompletionCall>,
419}
420
421impl<T> TypedPromptResponse<T> {
422 pub fn new(output: T, usage: Usage) -> Self {
423 Self {
424 output,
425 usage,
426 completion_calls: Vec::new(),
427 }
428 }
429
430 pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
432 self.completion_calls = completion_calls;
433 self
434 }
435
436 pub fn completion_calls(&self) -> &[CompletionCall] {
441 &self.completion_calls
442 }
443
444 pub fn requests(&self) -> usize {
446 self.completion_calls.len()
447 }
448}
449
450pub(crate) const TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER: &str =
451 "Tool not executed because another tool call in the same assistant turn was invalid.";
452
453pub(crate) fn build_history_for_request(
455 chat_history: Option<&[Message]>,
456 new_messages: &[Message],
457) -> Vec<Message> {
458 let input = chat_history.unwrap_or(&[]);
459 input.iter().chain(new_messages.iter()).cloned().collect()
460}
461
462pub(crate) fn build_full_history(
464 chat_history: Option<&[Message]>,
465 new_messages: Vec<Message>,
466) -> Vec<Message> {
467 let input = chat_history.unwrap_or(&[]);
468 input.iter().cloned().chain(new_messages).collect()
469}
470
471fn tool_result_with(
474 id: String,
475 call_id: Option<String>,
476 content: OneOrMany<ToolResultContent>,
477) -> UserContent {
478 match call_id {
479 Some(call_id) => UserContent::tool_result_with_call_id(id, call_id, content),
480 None => UserContent::tool_result(id, content),
481 }
482}
483
484pub(crate) fn tool_result_output(
488 id: String,
489 call_id: Option<String>,
490 output: String,
491) -> UserContent {
492 tool_result_with(id, call_id, ToolResultContent::from_tool_output(output))
493}
494
495pub(crate) fn tool_result_message(
501 id: String,
502 call_id: Option<String>,
503 message: String,
504) -> UserContent {
505 tool_result_with(
506 id,
507 call_id,
508 OneOrMany::one(ToolResultContent::text(message)),
509 )
510}
511
512pub(crate) fn invalid_tool_retry_user_message(
513 assistant_content: &OneOrMany<AssistantContent>,
514 invalid_tool_call_id: &str,
515 feedback: String,
516) -> Option<Message> {
517 let retry_results = assistant_content
518 .iter()
519 .filter_map(|content| match content {
520 AssistantContent::ToolCall(tool_call) if tool_call.id == invalid_tool_call_id => {
521 Some(tool_result_message(
522 tool_call.id.clone(),
523 tool_call.call_id.clone(),
524 feedback.clone(),
525 ))
526 }
527 AssistantContent::ToolCall(tool_call) => Some(tool_result_message(
528 tool_call.id.clone(),
529 tool_call.call_id.clone(),
530 TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
531 )),
532 _ => None,
533 })
534 .collect::<Vec<_>>();
535
536 Some(Message::User {
537 content: OneOrMany::from_iter_optional(retry_results)?,
538 })
539}
540
541pub(crate) fn is_empty_assistant_turn(choice: &OneOrMany<AssistantContent>) -> bool {
542 choice.len() == 1
543 && matches!(
544 choice.first(),
545 AssistantContent::Text(text) if text.text.is_empty() && text.additional_params.is_none()
546 )
547}
548
549pub(crate) fn assistant_text_from_choice(choice: &OneOrMany<AssistantContent>) -> String {
550 choice
551 .iter()
552 .filter_map(|content| match content {
553 AssistantContent::Text(text) => Some(text.text.as_str()),
554 _ => None,
555 })
556 .collect()
557}
558
559impl<M> PromptRequest<Extended, M>
560where
561 M: CompletionModel,
562{
563 async fn send(self) -> Result<PromptResponse, PromptError> {
564 self.runner.run().await
565 }
566}
567
568use crate::completion::StructuredOutputError;
573use schemars::{JsonSchema, schema_for};
574use serde::de::DeserializeOwned;
575
576pub struct TypedPromptRequest<T, S, M>
593where
594 T: JsonSchema + DeserializeOwned + WasmCompatSend,
595 S: PromptType,
596 M: CompletionModel,
597{
598 inner: PromptRequest<S, M>,
599 _phantom: std::marker::PhantomData<T>,
600}
601
602impl<T, M> TypedPromptRequest<T, Standard, M>
603where
604 T: JsonSchema + DeserializeOwned + WasmCompatSend,
605 M: CompletionModel,
606{
607 pub fn from_agent(agent: &Agent<M>, prompt: impl Into<Message>) -> Self {
611 let mut inner = PromptRequest::from_agent(agent, prompt);
612 inner.runner.output_schema = Some(schema_for!(T));
614 inner.runner.output_mode = OutputMode::Native;
621 Self {
622 inner,
623 _phantom: std::marker::PhantomData,
624 }
625 }
626}
627
628impl<T, S, M> TypedPromptRequest<T, S, M>
629where
630 T: JsonSchema + DeserializeOwned + WasmCompatSend,
631 S: PromptType,
632 M: CompletionModel,
633{
634 pub fn extended_details(self) -> TypedPromptRequest<T, Extended, M> {
640 TypedPromptRequest {
641 inner: self.inner.extended_details(),
642 _phantom: std::marker::PhantomData,
643 }
644 }
645
646 pub fn max_turns(mut self, max_turns: usize) -> Self {
651 self.inner = self.inner.max_turns(max_turns);
652 self
653 }
654
655 pub fn add_hook<H>(mut self, hook: H) -> Self
658 where
659 H: AgentHook<M> + 'static,
660 {
661 self.inner = self.inner.add_hook(hook);
662 self
663 }
664
665 forward_prompt_setters!(inner);
666 forward_tool_concurrency!(inner);
667}
668
669fn deserialize_structured_output<T: DeserializeOwned>(text: &str) -> Result<T, serde_json::Error> {
676 let trimmed = text.trim();
677 match serde_json::from_str::<T>(trimmed) {
678 Ok(value) => Ok(value),
679 Err(direct_err) => {
680 let Some(start) = trimmed.find(['{', '[']) else {
681 return Err(direct_err);
682 };
683 serde_json::Deserializer::from_str(&trimmed[start..])
684 .into_iter::<T>()
685 .next()
686 .unwrap_or(Err(direct_err))
687 }
688 }
689}
690
691impl<T, M> TypedPromptRequest<T, Standard, M>
692where
693 T: JsonSchema + DeserializeOwned + WasmCompatSend,
694 M: CompletionModel,
695{
696 async fn send(self) -> Result<T, StructuredOutputError> {
698 let response = self.inner.send().await.map_err(Box::new)?;
699
700 if response.is_empty() {
701 return Err(StructuredOutputError::EmptyResponse);
702 }
703
704 let parsed: T = deserialize_structured_output(&response)?;
705 Ok(parsed)
706 }
707}
708
709impl<T, M> TypedPromptRequest<T, Extended, M>
710where
711 T: JsonSchema + DeserializeOwned + WasmCompatSend,
712 M: CompletionModel,
713{
714 async fn send(self) -> Result<TypedPromptResponse<T>, StructuredOutputError> {
716 let response = self.inner.send().await.map_err(Box::new)?;
717
718 if response.output.is_empty() {
719 return Err(StructuredOutputError::EmptyResponse);
720 }
721
722 let parsed: T = deserialize_structured_output(&response.output)?;
723 Ok(TypedPromptResponse::new(parsed, response.usage)
724 .with_completion_calls(response.completion_calls))
725 }
726}
727
728impl<T, M> IntoFuture for TypedPromptRequest<T, Standard, M>
729where
730 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
731 M: CompletionModel + 'static,
732{
733 type Output = Result<T, StructuredOutputError>;
734 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
735
736 fn into_future(self) -> Self::IntoFuture {
737 Box::pin(self.send())
738 }
739}
740
741impl<T, M> IntoFuture for TypedPromptRequest<T, Extended, M>
742where
743 T: JsonSchema + DeserializeOwned + WasmCompatSend + 'static,
744 M: CompletionModel + 'static,
745{
746 type Output = Result<TypedPromptResponse<T>, StructuredOutputError>;
747 type IntoFuture = WasmBoxedFuture<'static, Self::Output>;
748
749 fn into_future(self) -> Self::IntoFuture {
750 Box::pin(self.send())
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::{CompletionCall, PromptResponse, PromptResponseRepr, TypedPromptResponse};
757 use crate::{
758 agent::{
759 AgentBuilder,
760 hook::{AgentHook, Flow, HookContext, InvalidToolCallContext, StepEvent},
761 },
762 completion::{
763 AssistantContent, CompletionError, CompletionRequest, Message, Prompt, PromptError,
764 StructuredOutputError, TypedPrompt, Usage,
765 },
766 message::{Text, ToolCall, ToolChoice, ToolFunction, UserContent},
767 test_utils::{
768 AppendFailingMemory, CountingMemory, FailingMemory, MockAddTool, MockCompletionModel,
769 MockExtensionsProbeTool, MockOperationArgs, MockSubtractTool, MockToolError, MockTurn,
770 SessionId,
771 },
772 tool::{Tool, ToolCallExtensions},
773 };
774 use schemars::JsonSchema;
775 use serde::{Deserialize, Serialize};
776 use serde_json::json;
777 use std::sync::{
778 Arc, Mutex,
779 atomic::{AtomicU32, Ordering},
780 };
781
782 #[derive(Serialize)]
783 struct SerializeOnly {
784 value: &'static str,
785 }
786
787 #[derive(Deserialize)]
788 struct DeserializeOnly {
789 value: String,
790 }
791
792 #[derive(Debug, Deserialize, JsonSchema, PartialEq)]
793 struct TypedAnswer {
794 value: String,
795 }
796
797 #[test]
798 fn deserialize_structured_output_tolerates_fences_and_prose() {
799 assert_eq!(
801 super::deserialize_structured_output::<TypedAnswer>(r#"{"value":"x"}"#).unwrap(),
802 TypedAnswer { value: "x".into() }
803 );
804 assert_eq!(
806 super::deserialize_structured_output::<TypedAnswer>("```json\n{\"value\":\"y\"}\n```")
807 .unwrap(),
808 TypedAnswer { value: "y".into() }
809 );
810 assert_eq!(
812 super::deserialize_structured_output::<TypedAnswer>(
813 "Here you go: {\"value\":\"z\"} — hope that helps!"
814 )
815 .unwrap(),
816 TypedAnswer { value: "z".into() }
817 );
818 assert!(super::deserialize_structured_output::<TypedAnswer>("no json here").is_err());
820 }
821
822 #[derive(Clone)]
823 struct PanicOnUnknownToolHook;
824
825 impl AgentHook<MockCompletionModel> for PanicOnUnknownToolHook {
826 async fn on_event(
827 &self,
828 _ctx: &HookContext,
829 event: StepEvent<'_, MockCompletionModel>,
830 ) -> Flow {
831 match event {
832 StepEvent::CompletionResponse { .. } => {
833 panic!("unknown tool response should fail before response hooks run")
834 }
835 StepEvent::ToolCall { .. } => {
836 panic!("unknown tool call should fail before tool hooks run")
837 }
838 _ => Flow::cont(),
839 }
840 }
841 }
842
843 #[derive(Clone)]
844 struct PanicOnToolCallHook;
845
846 impl AgentHook<MockCompletionModel> for PanicOnToolCallHook {
847 async fn on_event(
848 &self,
849 _ctx: &HookContext,
850 event: StepEvent<'_, MockCompletionModel>,
851 ) -> Flow {
852 match event {
853 StepEvent::ToolCall { .. } => {
854 panic!("recovered invalid turn should not invoke normal tool hooks")
855 }
856 _ => Flow::cont(),
857 }
858 }
859 }
860
861 #[derive(Clone)]
862 struct SkipDefaultApiAndPanicOnToolCallHook;
863
864 impl AgentHook<MockCompletionModel> for SkipDefaultApiAndPanicOnToolCallHook {
865 async fn on_event(
866 &self,
867 _ctx: &HookContext,
868 event: StepEvent<'_, MockCompletionModel>,
869 ) -> Flow {
870 match event {
871 StepEvent::InvalidToolCall(context) => {
872 SkipDefaultApiHook
873 .on_event(_ctx, StepEvent::InvalidToolCall(context))
874 .await
875 }
876 event @ StepEvent::ToolCall { .. } => {
877 PanicOnToolCallHook.on_event(_ctx, event).await
878 }
879 _ => Flow::cont(),
880 }
881 }
882 }
883
884 #[derive(Clone)]
885 struct RepairDefaultApiHook;
886
887 impl AgentHook<MockCompletionModel> for RepairDefaultApiHook {
888 async fn on_event(
889 &self,
890 _ctx: &HookContext,
891 event: StepEvent<'_, MockCompletionModel>,
892 ) -> Flow {
893 match event {
894 StepEvent::InvalidToolCall(context) => {
895 assert_eq!(context.tool_name, "default_api");
896 Flow::repair("add")
897 }
898 _ => Flow::cont(),
899 }
900 }
901 }
902
903 #[derive(Clone)]
904 struct RepairToSubtractHook;
905
906 impl AgentHook<MockCompletionModel> for RepairToSubtractHook {
907 async fn on_event(
908 &self,
909 _ctx: &HookContext,
910 event: StepEvent<'_, MockCompletionModel>,
911 ) -> Flow {
912 match event {
913 StepEvent::InvalidToolCall(_) => Flow::repair("subtract"),
914 _ => Flow::cont(),
915 }
916 }
917 }
918
919 #[derive(Clone)]
920 struct RetryDefaultApiHook;
921
922 impl AgentHook<MockCompletionModel> for RetryDefaultApiHook {
923 async fn on_event(
924 &self,
925 _ctx: &HookContext,
926 event: StepEvent<'_, MockCompletionModel>,
927 ) -> Flow {
928 match event {
929 StepEvent::InvalidToolCall(context) => {
930 let allowed_tools = &context.allowed_tools;
931 Flow::retry(format!("Use one of these tools instead: {allowed_tools:?}"))
932 }
933 _ => Flow::cont(),
934 }
935 }
936 }
937
938 #[derive(Clone)]
939 struct SkipDefaultApiHook;
940
941 impl AgentHook<MockCompletionModel> for SkipDefaultApiHook {
942 async fn on_event(
943 &self,
944 _ctx: &HookContext,
945 event: StepEvent<'_, MockCompletionModel>,
946 ) -> Flow {
947 match event {
948 StepEvent::InvalidToolCall(_) => Flow::skip("default_api is not available"),
949 _ => Flow::cont(),
950 }
951 }
952 }
953
954 #[derive(Clone, Default)]
955 struct RecordingInvalidToolCallHook {
956 contexts: Arc<Mutex<Vec<InvalidToolCallContext>>>,
957 }
958
959 impl RecordingInvalidToolCallHook {
960 fn observed(&self) -> Vec<InvalidToolCallContext> {
961 self.contexts
962 .lock()
963 .expect("invalid tool context records mutex was poisoned")
964 .clone()
965 }
966 }
967
968 impl AgentHook<MockCompletionModel> for RecordingInvalidToolCallHook {
969 async fn on_event(
970 &self,
971 _ctx: &HookContext,
972 event: StepEvent<'_, MockCompletionModel>,
973 ) -> Flow {
974 match event {
975 StepEvent::InvalidToolCall(context) => {
976 self.contexts
977 .lock()
978 .expect("invalid tool context records mutex was poisoned")
979 .push(context.clone());
980 Flow::fail()
981 }
982 _ => Flow::cont(),
983 }
984 }
985 }
986
987 #[derive(Clone)]
988 struct CountingAddTool {
989 calls: Arc<AtomicU32>,
990 }
991
992 impl Tool for CountingAddTool {
993 const NAME: &'static str = "add";
994 type Error = MockToolError;
995 type Args = MockOperationArgs;
996 type Output = i32;
997
998 fn description(&self) -> String {
999 MockAddTool.description()
1000 }
1001
1002 fn parameters(&self) -> serde_json::Value {
1003 MockAddTool.parameters()
1004 }
1005
1006 async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
1007 self.calls.fetch_add(1, Ordering::SeqCst);
1008 Ok(0)
1009 }
1010 }
1011
1012 fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
1013 Usage {
1014 input_tokens,
1015 output_tokens,
1016 total_tokens: input_tokens + output_tokens,
1017 cached_input_tokens: 0,
1018 cache_creation_input_tokens: 0,
1019 tool_use_prompt_tokens: 0,
1020 reasoning_tokens: 0,
1021 }
1022 }
1023
1024 #[test]
1025 fn typed_prompt_response_serializes_with_serialize_only_output() {
1026 let response = TypedPromptResponse::new(
1027 SerializeOnly { value: "ok" },
1028 Usage {
1029 input_tokens: 1,
1030 output_tokens: 2,
1031 total_tokens: 3,
1032 cached_input_tokens: 0,
1033 cache_creation_input_tokens: 0,
1034 tool_use_prompt_tokens: 0,
1035 reasoning_tokens: 0,
1036 },
1037 );
1038
1039 let json = serde_json::to_string(&response).expect("serialize typed prompt response");
1040 assert!(json.contains("\"value\":\"ok\""));
1041 }
1042
1043 #[test]
1044 fn typed_prompt_response_deserializes_with_deserialize_only_output() {
1045 let response: TypedPromptResponse<DeserializeOnly> = serde_json::from_str(
1046 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}}"#,
1047 )
1048 .expect("deserialize typed prompt response");
1049
1050 assert_eq!(response.requests(), 0);
1051 assert_eq!(response.output.value, "ok");
1052 assert_eq!(response.usage.input_tokens, 1);
1053 assert_eq!(response.usage.output_tokens, 2);
1054 assert_eq!(response.usage.total_tokens, 3);
1055 }
1056
1057 #[test]
1058 fn prompt_response_serializes_completion_calls_with_missing_usage() {
1059 let reported_usage = usage(3, 4);
1060 let response = PromptResponse::new("ok", reported_usage).with_completion_calls(vec![
1061 CompletionCall::new(0, Usage::new()),
1062 CompletionCall::new(1, reported_usage),
1063 ]);
1064
1065 let value = serde_json::to_value(&response).expect("serialize prompt response");
1066
1067 assert_eq!(
1071 value.get("completion_calls"),
1072 Some(&json!([
1073 {
1074 "call_index": 0,
1075 "usage": {
1076 "input_tokens": 0,
1077 "output_tokens": 0,
1078 "total_tokens": 0,
1079 "cached_input_tokens": 0,
1080 "cache_creation_input_tokens": 0,
1081 "tool_use_prompt_tokens": 0,
1082 "reasoning_tokens": 0,
1083 }
1084 },
1085 {
1086 "call_index": 1,
1087 "usage": {
1088 "input_tokens": 3,
1089 "output_tokens": 4,
1090 "total_tokens": 7,
1091 "cached_input_tokens": 0,
1092 "cache_creation_input_tokens": 0,
1093 "tool_use_prompt_tokens": 0,
1094 "reasoning_tokens": 0,
1095 }
1096 }
1097 ]))
1098 );
1099
1100 let response: PromptResponse =
1101 serde_json::from_value(value).expect("deserialize prompt response");
1102 assert_eq!(
1103 response.completion_calls(),
1104 &[
1105 CompletionCall::new(0, Usage::new()),
1106 CompletionCall::new(1, reported_usage)
1107 ]
1108 );
1109 assert_eq!(response.requests(), 2);
1110 }
1111
1112 #[test]
1113 fn prompt_response_deserializes_pre_monoid_null_usage_format() {
1114 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"}]}]}"#;
1117
1118 let response: PromptResponse =
1119 serde_json::from_str(fixture).expect("old-format response should deserialize");
1120 assert_eq!(
1121 response.completion_calls(),
1122 &[
1123 CompletionCall::new(0, Usage::new()),
1124 CompletionCall::new(1, usage(3, 4))
1125 ]
1126 );
1127 }
1128
1129 #[test]
1130 fn prompt_response_missing_content_reconstructs_from_output() {
1131 let mut value = serde_json::to_value(PromptResponse::new("hello", Usage::new()))
1135 .expect("serialize prompt response");
1136 value
1137 .as_object_mut()
1138 .expect("prompt response serializes to a JSON object")
1139 .remove("content");
1140 assert!(
1141 value.get("content").is_none(),
1142 "fixture must omit the content field to model legacy data"
1143 );
1144
1145 let response: PromptResponse = serde_json::from_value(value)
1146 .expect("legacy response without content should deserialize");
1147
1148 assert_eq!(response.output(), "hello");
1149 assert_eq!(response.content().iter().count(), 1);
1150 assert_eq!(response.content().first(), AssistantContent::text("hello"));
1151 }
1152
1153 #[test]
1154 fn prompt_response_missing_content_empty_output_stays_empty_text() {
1155 let mut value =
1156 serde_json::to_value(PromptResponse::empty()).expect("serialize prompt response");
1157 value
1158 .as_object_mut()
1159 .expect("prompt response serializes to a JSON object")
1160 .remove("content");
1161
1162 let response: PromptResponse = serde_json::from_value(value)
1163 .expect("legacy empty response without content should deserialize");
1164
1165 assert_eq!(response.output(), "");
1166 assert_eq!(response.content().first(), AssistantContent::text(""));
1167 }
1168
1169 #[test]
1170 fn prompt_response_roundtrip_preserves_explicit_content() {
1171 let response = PromptResponse::new("visible text", Usage::new())
1175 .with_content(crate::OneOrMany::one(AssistantContent::text("structured")));
1176
1177 let value = serde_json::to_value(&response).expect("serialize prompt response");
1178 assert!(
1179 value.get("content").is_some(),
1180 "content is part of the serialized shape"
1181 );
1182
1183 let round: PromptResponse =
1184 serde_json::from_value(value).expect("deserialize prompt response");
1185 assert_eq!(round.output(), "visible text");
1186 let AssistantContent::Text(text) = round.content().first() else {
1191 panic!("expected text content, got {:?}", round.content().first());
1192 };
1193 assert_eq!(text.text, "structured");
1194 }
1195
1196 #[test]
1197 fn prompt_response_serialize_and_deserialize_agree_on_wire_shape() {
1198 let response = PromptResponse::new("hi", usage(1, 2))
1207 .with_completion_calls(vec![CompletionCall::new(0, usage(1, 2))]);
1208
1209 let from_response = serde_json::to_value(&response).expect("serialize response");
1210 let from_shadow = serde_json::to_value(PromptResponseRepr::from(response.clone()))
1211 .expect("serialize shadow");
1212 assert_eq!(
1213 from_response, from_shadow,
1214 "serialize must route through the same shadow as deserialize"
1215 );
1216
1217 let round: PromptResponse =
1219 serde_json::from_value(from_response).expect("deserialize response");
1220 assert_eq!(round.output(), "hi");
1221 assert_eq!(round.usage(), usage(1, 2));
1222 assert_eq!(
1223 round.completion_calls(),
1224 &[CompletionCall::new(0, usage(1, 2))]
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn prompt_response_records_completion_call_without_reported_usage() {
1230 let model = MockCompletionModel::new([MockTurn::text("ok")]);
1231 let agent = AgentBuilder::new(model).build();
1232
1233 let response = agent
1234 .prompt("say ok")
1235 .extended_details()
1236 .await
1237 .expect("prompt should succeed");
1238
1239 assert_eq!(response.output, "ok");
1240 assert_eq!(response.usage, Usage::new());
1241 assert_eq!(
1242 response.completion_calls(),
1243 &[CompletionCall::new(0, Usage::new())]
1244 );
1245 }
1246
1247 #[tokio::test]
1248 async fn typed_prompt_response_preserves_completion_calls() {
1249 let call_usage = Usage {
1250 input_tokens: 4,
1251 output_tokens: 6,
1252 total_tokens: 10,
1253 cached_input_tokens: 0,
1254 cache_creation_input_tokens: 0,
1255 tool_use_prompt_tokens: 0,
1256 reasoning_tokens: 0,
1257 };
1258 let model =
1259 MockCompletionModel::new([MockTurn::text(r#"{"value":"ok"}"#).with_usage(call_usage)]);
1260 let agent = AgentBuilder::new(model).build();
1261
1262 let response = agent
1263 .prompt_typed::<TypedAnswer>("return typed json")
1264 .extended_details()
1265 .await
1266 .expect("typed prompt should succeed");
1267
1268 assert_eq!(
1269 response.output,
1270 TypedAnswer {
1271 value: "ok".to_string()
1272 }
1273 );
1274 assert_eq!(response.usage, call_usage);
1275 assert_eq!(
1276 response.completion_calls(),
1277 &[CompletionCall::new(0, call_usage)]
1278 );
1279 }
1280
1281 fn validate_follow_up_tool_history(request: &CompletionRequest) {
1282 let history = request.chat_history.iter().cloned().collect::<Vec<_>>();
1283 assert_eq!(
1284 history.len(),
1285 3,
1286 "follow-up request should contain the prompt, assistant tool call, and user tool result: {history:?}"
1287 );
1288
1289 assert!(matches!(
1290 history.first(),
1291 Some(Message::User { content })
1292 if matches!(
1293 content.first(),
1294 UserContent::Text(text) if text.text == "do tool work"
1295 )
1296 ));
1297
1298 assert!(matches!(
1299 history.get(1),
1300 Some(Message::Assistant { content, .. })
1301 if matches!(
1302 content.first(),
1303 AssistantContent::ToolCall(tool_call)
1304 if tool_call.id == "tool_call_1"
1305 && tool_call.call_id.as_deref() == Some("call_1")
1306 )
1307 ));
1308
1309 assert!(matches!(
1310 history.get(2),
1311 Some(Message::User { content })
1312 if matches!(
1313 content.first(),
1314 UserContent::ToolResult(tool_result)
1315 if tool_result.id == "tool_call_1"
1316 && tool_result.call_id.as_deref() == Some("call_1")
1317 )
1318 ));
1319 }
1320
1321 fn history_contains_tool_call(history: &[Message], tool_name: &str) -> bool {
1322 history.iter().any(|message| {
1323 matches!(
1324 message,
1325 Message::Assistant { content, .. }
1326 if content.iter().any(|item| matches!(
1327 item,
1328 AssistantContent::ToolCall(tool_call)
1329 if tool_call.function.name == tool_name
1330 ))
1331 )
1332 })
1333 }
1334
1335 #[tokio::test]
1336 async fn unknown_tool_call_fails_before_non_streaming_second_request() {
1337 let model = MockCompletionModel::new([
1338 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2})),
1339 MockTurn::text("should not be requested"),
1340 ]);
1341 let recorded = model.clone();
1342 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1343
1344 let err = agent
1345 .prompt("use the tool")
1346 .add_hook(PanicOnUnknownToolHook)
1347 .max_turns(3)
1348 .await
1349 .expect_err("unknown model-emitted tool should fail");
1350
1351 match err {
1352 PromptError::UnknownToolCall {
1353 tool_name,
1354 available_tools,
1355 allowed_tools,
1356 chat_history,
1357 } => {
1358 assert_eq!(tool_name, "default_api");
1359 assert_eq!(available_tools, vec!["add".to_string()]);
1360 assert_eq!(allowed_tools, vec!["add".to_string()]);
1361 assert!(history_contains_tool_call(&chat_history, "default_api"));
1362 }
1363 other => panic!("expected UnknownToolCall, got {other:?}"),
1364 }
1365 assert_eq!(recorded.request_count(), 1);
1366 }
1367
1368 #[tokio::test]
1371 async fn tool_extensions_reach_tool_through_agent_loop() {
1372 let model = MockCompletionModel::new([
1373 MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1374 MockTurn::text("done"),
1375 ]);
1376 let probe = MockExtensionsProbeTool::default();
1377 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1378
1379 let mut extensions = ToolCallExtensions::new();
1380 extensions.insert(SessionId("abc-123".to_string()));
1381
1382 let out = agent
1383 .prompt("use the tool")
1384 .tool_extensions(extensions)
1385 .max_turns(3)
1386 .await
1387 .expect("run succeeds");
1388
1389 assert_eq!(out, "done");
1390 assert_eq!(probe.observed().as_deref(), Some("session:abc-123"));
1391 }
1392
1393 #[tokio::test]
1397 async fn tool_extensions_persist_across_multiple_rounds() {
1398 let model = MockCompletionModel::new([
1399 MockTurn::tool_call("c1", "context_probe", json!({})),
1400 MockTurn::tool_call("c2", "context_probe", json!({})),
1401 MockTurn::text("done"),
1402 ]);
1403 let probe = MockExtensionsProbeTool::default();
1404 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1405
1406 let mut extensions = ToolCallExtensions::new();
1407 extensions.insert(SessionId("abc-123".to_string()));
1408
1409 let out = agent
1410 .prompt("use the tool twice")
1411 .tool_extensions(extensions)
1412 .max_turns(5)
1413 .await
1414 .expect("run succeeds");
1415
1416 assert_eq!(out, "done");
1417 assert_eq!(
1418 probe.observations(),
1419 vec!["session:abc-123".to_string(), "session:abc-123".to_string()],
1420 );
1421 }
1422
1423 #[tokio::test]
1426 async fn tool_runs_with_empty_context_when_none_supplied() {
1427 let model = MockCompletionModel::new([
1428 MockTurn::tool_call("tool_call_1", "context_probe", json!({})),
1429 MockTurn::text("done"),
1430 ]);
1431 let probe = MockExtensionsProbeTool::default();
1432 let agent = AgentBuilder::new(model).tool(probe.clone()).build();
1433
1434 let out = agent
1435 .prompt("use the tool")
1436 .max_turns(3)
1437 .await
1438 .expect("run succeeds");
1439
1440 assert_eq!(out, "done");
1441 assert_eq!(probe.observed().as_deref(), Some("no-session"));
1445 }
1446
1447 #[tokio::test]
1452 async fn probe_plain_call_records_sentinel() {
1453 let probe = MockExtensionsProbeTool::default();
1454 let out = probe.call(json!({})).await.expect("call succeeds");
1455 assert_eq!(out, "call-no-context");
1456 assert_eq!(probe.observed().as_deref(), Some("call-no-context"));
1457 }
1458
1459 #[tokio::test]
1460 async fn invalid_tool_call_context_uses_completed_tool_call_provider_id() {
1461 let invalid_hook = RecordingInvalidToolCallHook::default();
1462 let model = MockCompletionModel::new([
1463 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 1, "y": 2}))
1464 .with_call_id("provider_call_1"),
1465 MockTurn::text("should not be requested"),
1466 ]);
1467 let recorded = model.clone();
1468 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1469
1470 let err = agent
1471 .prompt("use the tool")
1472 .add_hook(invalid_hook.clone())
1473 .max_turns(3)
1474 .await
1475 .expect_err("invalid tool should fail");
1476
1477 assert!(matches!(err, PromptError::UnknownToolCall { .. }));
1478 assert_eq!(recorded.request_count(), 1);
1479 let contexts = invalid_hook.observed();
1480 assert_eq!(contexts.len(), 1);
1481 let context = &contexts[0];
1482 assert_eq!(context.tool_name, "default_api");
1483 assert_eq!(context.tool_call_id.as_deref(), Some("tool_call_1"));
1484 assert_eq!(context.internal_call_id, None);
1485 assert!(!context.is_streaming);
1486 }
1487
1488 #[tokio::test]
1489 async fn disallowed_specific_tool_call_fails_before_non_streaming_second_request() {
1490 let model = MockCompletionModel::new([
1491 MockTurn::tool_call("tool_call_1", "subtract", json!({"x": 3, "y": 1})),
1492 MockTurn::text("should not be requested"),
1493 ]);
1494 let recorded = model.clone();
1495 let agent = AgentBuilder::new(model)
1496 .tool(MockAddTool)
1497 .tool(MockSubtractTool)
1498 .tool_choice(ToolChoice::Specific {
1499 function_names: vec!["add".to_string()],
1500 })
1501 .build();
1502
1503 let err = agent
1504 .prompt("use the allowed tool")
1505 .add_hook(PanicOnUnknownToolHook)
1506 .max_turns(3)
1507 .await
1508 .expect_err("disallowed model-emitted tool should fail");
1509
1510 match err {
1511 PromptError::UnknownToolCall {
1512 tool_name,
1513 available_tools,
1514 allowed_tools,
1515 chat_history,
1516 } => {
1517 assert_eq!(tool_name, "subtract");
1518 assert_eq!(
1519 available_tools,
1520 vec!["add".to_string(), "subtract".to_string()]
1521 );
1522 assert_eq!(allowed_tools, vec!["add".to_string()]);
1523 assert!(history_contains_tool_call(&chat_history, "subtract"));
1524 }
1525 other => panic!("expected UnknownToolCall, got {other:?}"),
1526 }
1527 assert_eq!(recorded.request_count(), 1);
1528 }
1529
1530 #[tokio::test]
1531 async fn tool_choice_none_rejects_non_streaming_tool_call() {
1532 let model = MockCompletionModel::new([
1533 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
1534 MockTurn::text("should not be requested"),
1535 ]);
1536 let recorded = model.clone();
1537 let agent = AgentBuilder::new(model)
1538 .tool(MockAddTool)
1539 .tool_choice(ToolChoice::None)
1540 .build();
1541
1542 let err = agent
1543 .prompt("do not use tools")
1544 .add_hook(PanicOnUnknownToolHook)
1545 .max_turns(3)
1546 .await
1547 .expect_err("ToolChoice::None should reject returned tool calls");
1548
1549 match err {
1550 PromptError::UnknownToolCall {
1551 tool_name,
1552 available_tools,
1553 allowed_tools,
1554 chat_history,
1555 } => {
1556 assert_eq!(tool_name, "add");
1557 assert_eq!(available_tools, vec!["add".to_string()]);
1558 assert!(allowed_tools.is_empty());
1559 assert!(history_contains_tool_call(&chat_history, "add"));
1560 }
1561 other => panic!("expected UnknownToolCall, got {other:?}"),
1562 }
1563 assert_eq!(recorded.request_count(), 1);
1564 }
1565
1566 #[tokio::test]
1567 async fn invalid_tool_call_hook_can_repair_non_streaming_tool_name() {
1568 let model = MockCompletionModel::new([
1569 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1570 MockTurn::text("done"),
1571 ]);
1572 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1573
1574 let response = agent
1575 .prompt("add")
1576 .add_hook(RepairDefaultApiHook)
1577 .max_turns(3)
1578 .extended_details()
1579 .await
1580 .expect("repaired tool call should execute");
1581
1582 assert_eq!(response.output, "done");
1583 let messages = response.messages.expect("messages should be present");
1584 assert!(history_contains_tool_call(&messages, "add"));
1585 assert!(!history_contains_tool_call(&messages, "default_api"));
1586 assert!(messages.iter().any(|message| {
1587 matches!(
1588 message,
1589 Message::User { content }
1590 if content.iter().any(|content| {
1591 matches!(
1592 content,
1593 UserContent::ToolResult(result)
1594 if result.content.iter().any(|content| {
1595 matches!(
1596 content,
1597 crate::message::ToolResultContent::Text(text)
1598 if text.text == "5"
1599 )
1600 })
1601 )
1602 })
1603 )
1604 }));
1605 }
1606
1607 #[tokio::test]
1608 async fn invalid_tool_call_hook_retry_adds_feedback_and_retries_non_streaming() {
1609 let model = MockCompletionModel::new([
1610 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1611 MockTurn::text("retried"),
1612 ]);
1613 let recorded = model.clone();
1614 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1615
1616 let response = agent
1617 .prompt("add")
1618 .add_hook(RetryDefaultApiHook)
1619 .max_invalid_tool_call_retries(1)
1620 .max_turns(3)
1621 .extended_details()
1622 .await
1623 .expect("retry should recover");
1624
1625 assert_eq!(response.output, "retried");
1626 assert_eq!(recorded.request_count(), 2);
1627 let messages = response.messages.expect("messages should be present");
1628 assert!(messages.iter().any(|message| {
1629 matches!(
1630 message,
1631 Message::User { content }
1632 if content.iter().any(|content| {
1633 matches!(
1634 content,
1635 UserContent::ToolResult(result)
1636 if result.content.iter().any(|content| {
1637 matches!(
1638 content,
1639 crate::message::ToolResultContent::Text(text)
1640 if text.text.contains("Use one of these tools instead")
1641 )
1642 })
1643 )
1644 })
1645 )
1646 }));
1647 }
1648
1649 #[tokio::test]
1650 async fn invalid_tool_call_hook_retries_mixed_non_streaming_turn_without_executing_valid_call()
1651 {
1652 let add_calls = Arc::new(AtomicU32::new(0));
1653 let mut valid_tool_call = ToolCall::new(
1654 "tool_call_1".to_string(),
1655 ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
1656 );
1657 valid_tool_call.call_id = Some("call_1".to_string());
1658 let mut invalid_tool_call = ToolCall::new(
1659 "tool_call_2".to_string(),
1660 ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
1661 );
1662 invalid_tool_call.call_id = Some("call_2".to_string());
1663 let model = MockCompletionModel::new([
1664 MockTurn::from_contents([
1665 AssistantContent::ToolCall(valid_tool_call),
1666 AssistantContent::ToolCall(invalid_tool_call),
1667 ])
1668 .expect("tool-call response should be non-empty"),
1669 MockTurn::text("retried"),
1670 ]);
1671 let recorded = model.clone();
1672 let agent = AgentBuilder::new(model)
1673 .tool(CountingAddTool {
1674 calls: add_calls.clone(),
1675 })
1676 .build();
1677
1678 let response = agent
1679 .prompt("add")
1680 .add_hook(RetryDefaultApiHook)
1681 .max_invalid_tool_call_retries(1)
1682 .max_turns(3)
1683 .extended_details()
1684 .await
1685 .expect("retry should recover");
1686
1687 assert_eq!(response.output, "retried");
1688 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
1689 let requests = recorded.requests();
1690 assert_eq!(requests.len(), 2);
1691 let retry_history = requests[1].chat_history.iter().cloned().collect::<Vec<_>>();
1692 assert_eq!(retry_history.len(), 3);
1693 assert!(matches!(
1694 retry_history.get(1),
1695 Some(Message::Assistant { content, .. })
1696 if content.iter().any(|item| matches!(
1697 item,
1698 AssistantContent::ToolCall(tool_call)
1699 if tool_call.id == "tool_call_1"
1700 && tool_call.function.name == "add"
1701 ))
1702 && content.iter().any(|item| matches!(
1703 item,
1704 AssistantContent::ToolCall(tool_call)
1705 if tool_call.id == "tool_call_2"
1706 && tool_call.function.name == "default_api"
1707 ))
1708 ));
1709 assert!(matches!(
1710 retry_history.get(2),
1711 Some(Message::User { content })
1712 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
1713 && content.iter().any(|item| matches!(
1714 item,
1715 UserContent::ToolResult(result)
1716 if result.id == "tool_call_1"
1717 && result.call_id.as_deref() == Some("call_1")
1718 && result.content.iter().any(|content| matches!(
1719 content,
1720 crate::message::ToolResultContent::Text(text)
1721 if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
1722 ))
1723 ))
1724 && content.iter().any(|item| matches!(
1725 item,
1726 UserContent::ToolResult(result)
1727 if result.id == "tool_call_2"
1728 && result.call_id.as_deref() == Some("call_2")
1729 && result.content.iter().any(|content| matches!(
1730 content,
1731 crate::message::ToolResultContent::Text(text)
1732 if text.text.contains("Use one of these tools instead")
1733 ))
1734 ))
1735 ));
1736 }
1737
1738 #[tokio::test]
1739 async fn invalid_tool_call_hook_skips_mixed_non_streaming_turn_without_executing_valid_call() {
1740 let add_calls = Arc::new(AtomicU32::new(0));
1741 let mut valid_tool_call = ToolCall::new(
1742 "tool_call_1".to_string(),
1743 ToolFunction::new("add".to_string(), json!({"x": 2, "y": 3})),
1744 );
1745 valid_tool_call.call_id = Some("call_1".to_string());
1746 let mut invalid_tool_call = ToolCall::new(
1747 "tool_call_2".to_string(),
1748 ToolFunction::new("default_api".to_string(), json!({"x": 4, "y": 5})),
1749 );
1750 invalid_tool_call.call_id = Some("call_2".to_string());
1751 let model = MockCompletionModel::new([
1752 MockTurn::from_contents([
1753 AssistantContent::ToolCall(valid_tool_call),
1754 AssistantContent::ToolCall(invalid_tool_call),
1755 ])
1756 .expect("tool-call response should be non-empty"),
1757 MockTurn::text("skipped"),
1758 ]);
1759 let agent = AgentBuilder::new(model)
1760 .tool(CountingAddTool {
1761 calls: add_calls.clone(),
1762 })
1763 .build();
1764
1765 let response = agent
1766 .prompt("add")
1767 .add_hook(SkipDefaultApiAndPanicOnToolCallHook)
1768 .max_turns(3)
1769 .extended_details()
1770 .await
1771 .expect("skip should recover without executing peer tools");
1772
1773 assert_eq!(response.output, "skipped");
1774 assert_eq!(add_calls.load(Ordering::SeqCst), 0);
1775 let messages = response.messages.expect("messages should be present");
1776 assert!(history_contains_tool_call(&messages, "add"));
1777 assert!(history_contains_tool_call(&messages, "default_api"));
1778 assert!(matches!(
1779 messages.get(2),
1780 Some(Message::User { content })
1781 if content.iter().filter(|item| matches!(item, UserContent::ToolResult(_))).count() == 2
1782 && content.iter().any(|item| matches!(
1783 item,
1784 UserContent::ToolResult(result)
1785 if result.id == "tool_call_1"
1786 && result.call_id.as_deref() == Some("call_1")
1787 && result.content.iter().any(|content| matches!(
1788 content,
1789 crate::message::ToolResultContent::Text(text)
1790 if text.text == super::TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER
1791 ))
1792 ))
1793 && content.iter().any(|item| matches!(
1794 item,
1795 UserContent::ToolResult(result)
1796 if result.id == "tool_call_2"
1797 && result.call_id.as_deref() == Some("call_2")
1798 && result.content.iter().any(|content| matches!(
1799 content,
1800 crate::message::ToolResultContent::Text(text)
1801 if text.text == "default_api is not available"
1802 ))
1803 ))
1804 ));
1805 }
1806
1807 #[tokio::test]
1808 async fn invalid_tool_call_hook_retry_budget_exhaustion_fails() {
1809 let model = MockCompletionModel::new([
1810 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1811 MockTurn::text("should not be requested"),
1812 ]);
1813 let recorded = model.clone();
1814 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1815
1816 let err = agent
1817 .prompt("add")
1818 .add_hook(RetryDefaultApiHook)
1819 .max_invalid_tool_call_retries(0)
1820 .max_turns(3)
1821 .await
1822 .expect_err("retry without budget should fail");
1823
1824 match err {
1825 PromptError::UnknownToolCall {
1826 tool_name,
1827 chat_history,
1828 ..
1829 } => {
1830 assert_eq!(tool_name, "default_api");
1831 assert!(history_contains_tool_call(&chat_history, "default_api"));
1832 }
1833 other => panic!("expected UnknownToolCall, got {other:?}"),
1834 }
1835 assert_eq!(recorded.request_count(), 1);
1836 }
1837
1838 #[tokio::test]
1839 async fn invalid_tool_call_hook_can_skip_structured_non_streaming_call() {
1840 let model = MockCompletionModel::new([
1841 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1842 MockTurn::text("skipped"),
1843 ]);
1844 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
1845
1846 let response = agent
1847 .prompt("add")
1848 .add_hook(SkipDefaultApiHook)
1849 .max_turns(3)
1850 .extended_details()
1851 .await
1852 .expect("skip should continue with synthetic tool result");
1853
1854 assert_eq!(response.output, "skipped");
1855 let messages = response.messages.expect("messages should be present");
1856 assert!(history_contains_tool_call(&messages, "default_api"));
1857 assert!(messages.iter().any(|message| {
1858 matches!(
1859 message,
1860 Message::User { content }
1861 if content.iter().any(|content| {
1862 matches!(
1863 content,
1864 UserContent::ToolResult(result)
1865 if result.content.iter().any(|content| {
1866 matches!(
1867 content,
1868 crate::message::ToolResultContent::Text(text)
1869 if text.text == "default_api is not available"
1870 )
1871 })
1872 )
1873 })
1874 )
1875 }));
1876 }
1877
1878 #[tokio::test]
1879 async fn skip_under_specific_tool_choice_returns_synthetic_feedback() {
1880 let model = MockCompletionModel::new([
1881 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1882 MockTurn::text("skipped"),
1883 ]);
1884 let agent = AgentBuilder::new(model)
1885 .tool(MockAddTool)
1886 .tool_choice(ToolChoice::Specific {
1887 function_names: vec!["add".to_string()],
1888 })
1889 .build();
1890
1891 let response = agent
1892 .prompt("add")
1893 .add_hook(SkipDefaultApiHook)
1894 .max_turns(3)
1895 .extended_details()
1896 .await
1897 .expect("skip should produce synthetic feedback under Specific");
1898
1899 assert_eq!(response.output, "skipped");
1900 let messages = response.messages.expect("messages should be present");
1901 assert!(history_contains_tool_call(&messages, "default_api"));
1902 assert!(messages.iter().any(|message| {
1903 matches!(
1904 message,
1905 Message::User { content }
1906 if content.iter().any(|content| {
1907 matches!(
1908 content,
1909 UserContent::ToolResult(result)
1910 if result.id == "tool_call_1"
1911 && result.content.iter().any(|content| {
1912 matches!(
1913 content,
1914 crate::message::ToolResultContent::Text(text)
1915 if text.text == "default_api is not available"
1916 )
1917 })
1918 )
1919 })
1920 )
1921 }));
1922 }
1923
1924 #[tokio::test]
1925 async fn repair_to_disallowed_specific_tool_fails() {
1926 let model = MockCompletionModel::new([
1927 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1928 MockTurn::text("should not be requested"),
1929 ]);
1930 let recorded = model.clone();
1931 let agent = AgentBuilder::new(model)
1932 .tool(MockAddTool)
1933 .tool(MockSubtractTool)
1934 .tool_choice(ToolChoice::Specific {
1935 function_names: vec!["add".to_string()],
1936 })
1937 .build();
1938
1939 let err = agent
1940 .prompt("add")
1941 .add_hook(RepairToSubtractHook)
1942 .max_turns(3)
1943 .await
1944 .expect_err("repair to a disallowed tool should fail");
1945
1946 match err {
1947 PromptError::UnknownToolCall { tool_name, .. } => {
1948 assert_eq!(tool_name, "subtract");
1949 }
1950 other => panic!("expected UnknownToolCall, got {other:?}"),
1951 }
1952 assert_eq!(recorded.request_count(), 1);
1953 }
1954
1955 #[tokio::test]
1956 async fn repair_under_tool_choice_none_fails() {
1957 let model = MockCompletionModel::new([
1958 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1959 MockTurn::text("should not be requested"),
1960 ]);
1961 let recorded = model.clone();
1962 let agent = AgentBuilder::new(model)
1963 .tool(MockAddTool)
1964 .tool_choice(ToolChoice::None)
1965 .build();
1966
1967 let err = agent
1968 .prompt("do not use tools")
1969 .add_hook(RepairDefaultApiHook)
1970 .max_turns(3)
1971 .await
1972 .expect_err("ToolChoice::None should reject repaired tool calls");
1973
1974 match err {
1975 PromptError::UnknownToolCall { tool_name, .. } => {
1976 assert_eq!(tool_name, "add");
1977 }
1978 other => panic!("expected UnknownToolCall, got {other:?}"),
1979 }
1980 assert_eq!(recorded.request_count(), 1);
1981 }
1982
1983 #[tokio::test]
1984 async fn skip_under_tool_choice_none_fails() {
1985 let model = MockCompletionModel::new([
1986 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
1987 MockTurn::text("should not be requested"),
1988 ]);
1989 let recorded = model.clone();
1990 let agent = AgentBuilder::new(model)
1991 .tool(MockAddTool)
1992 .tool_choice(ToolChoice::None)
1993 .build();
1994
1995 let err = agent
1996 .prompt("do not use tools")
1997 .add_hook(SkipDefaultApiHook)
1998 .max_turns(3)
1999 .await
2000 .expect_err("ToolChoice::None should reject skipped tool calls");
2001
2002 match err {
2003 PromptError::UnknownToolCall { tool_name, .. } => {
2004 assert_eq!(tool_name, "default_api");
2005 }
2006 other => panic!("expected UnknownToolCall, got {other:?}"),
2007 }
2008 assert_eq!(recorded.request_count(), 1);
2009 }
2010
2011 #[tokio::test]
2012 async fn typed_prompt_default_invalid_tool_call_fails_fast() {
2013 let model = MockCompletionModel::new([
2014 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2015 MockTurn::text(r#"{"value":"should not be requested"}"#),
2016 ]);
2017 let recorded = model.clone();
2018 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2019
2020 let err = agent
2021 .prompt_typed::<TypedAnswer>("return typed json")
2022 .add_hook(PanicOnUnknownToolHook)
2023 .max_turns(3)
2024 .await
2025 .expect_err("typed prompt should preserve fail-fast default");
2026
2027 match err {
2028 StructuredOutputError::PromptError(err) => match *err {
2029 PromptError::UnknownToolCall { tool_name, .. } => {
2030 assert_eq!(tool_name, "default_api");
2031 }
2032 other => panic!("expected UnknownToolCall, got {other:?}"),
2033 },
2034 other => panic!("expected prompt error, got {other:?}"),
2035 }
2036 assert_eq!(recorded.request_count(), 1);
2037 }
2038
2039 #[tokio::test]
2040 async fn typed_prompt_invalid_tool_call_hook_can_repair_tool_name() {
2041 let model = MockCompletionModel::new([
2042 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2043 MockTurn::text(r#"{"value":"repaired"}"#),
2044 ]);
2045 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2046
2047 let response = agent
2048 .prompt_typed::<TypedAnswer>("return typed json")
2049 .add_hook(RepairDefaultApiHook)
2050 .max_turns(3)
2051 .await
2052 .expect("typed prompt should repair invalid tool call");
2053
2054 assert_eq!(
2055 response,
2056 TypedAnswer {
2057 value: "repaired".to_string()
2058 }
2059 );
2060 }
2061
2062 #[tokio::test]
2063 async fn typed_prompt_invalid_tool_call_hook_can_retry_and_parse_response() {
2064 let model = MockCompletionModel::new([
2065 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2066 MockTurn::text(r#"{"value":"retried"}"#),
2067 ]);
2068 let recorded = model.clone();
2069 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2070
2071 let response = agent
2072 .prompt_typed::<TypedAnswer>("return typed json")
2073 .add_hook(RetryDefaultApiHook)
2074 .max_invalid_tool_call_retries(1)
2075 .max_turns(3)
2076 .await
2077 .expect("typed prompt should retry invalid tool call");
2078
2079 assert_eq!(
2080 response,
2081 TypedAnswer {
2082 value: "retried".to_string()
2083 }
2084 );
2085 assert_eq!(recorded.request_count(), 2);
2086 }
2087
2088 #[tokio::test]
2089 async fn typed_prompt_invalid_tool_call_retry_budget_exhaustion_fails() {
2090 let model = MockCompletionModel::new([
2091 MockTurn::tool_call("tool_call_1", "default_api", json!({"x": 2, "y": 3})),
2092 MockTurn::text(r#"{"value":"should not be requested"}"#),
2093 ]);
2094 let recorded = model.clone();
2095 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2096
2097 let err = agent
2098 .prompt_typed::<TypedAnswer>("return typed json")
2099 .add_hook(RetryDefaultApiHook)
2100 .max_invalid_tool_call_retries(0)
2101 .max_turns(3)
2102 .await
2103 .expect_err("typed prompt should fail when retry budget is exhausted");
2104
2105 match err {
2106 StructuredOutputError::PromptError(err) => match *err {
2107 PromptError::UnknownToolCall { tool_name, .. } => {
2108 assert_eq!(tool_name, "default_api");
2109 }
2110 other => panic!("expected UnknownToolCall, got {other:?}"),
2111 },
2112 other => panic!("expected prompt error, got {other:?}"),
2113 }
2114 assert_eq!(recorded.request_count(), 1);
2115 }
2116
2117 #[tokio::test]
2118 async fn invalid_specific_tool_choice_fails_before_non_streaming_provider_request() {
2119 let model = MockCompletionModel::text("should not be requested");
2120 let recorded = model.clone();
2121 let agent = AgentBuilder::new(model)
2122 .tool(MockAddTool)
2123 .tool_choice(ToolChoice::Specific {
2124 function_names: vec!["missing".to_string()],
2125 })
2126 .build();
2127
2128 let err = agent
2129 .prompt("use the missing tool")
2130 .await
2131 .expect_err("invalid ToolChoice::Specific should fail before provider request");
2132
2133 match err {
2134 PromptError::CompletionError(CompletionError::RequestError(err)) => {
2135 let msg = err.to_string();
2136 assert!(msg.contains("missing"), "got: {msg}");
2137 assert!(msg.contains("add"), "got: {msg}");
2138 }
2139 other => panic!("expected CompletionError::RequestError, got {other:?}"),
2140 }
2141 assert_eq!(recorded.request_count(), 0);
2142 }
2143
2144 #[tokio::test]
2145 async fn allowed_specific_tool_call_executes_normally() {
2146 let model = MockCompletionModel::new([
2147 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2})),
2148 MockTurn::text("done"),
2149 ]);
2150 let recorded = model.clone();
2151 let agent = AgentBuilder::new(model)
2152 .tool(MockAddTool)
2153 .tool_choice(ToolChoice::Specific {
2154 function_names: vec!["add".to_string()],
2155 })
2156 .build();
2157
2158 let response = agent
2159 .prompt("use the allowed tool")
2160 .max_turns(3)
2161 .await
2162 .expect("allowed specific tool should execute");
2163
2164 assert_eq!(response, "done");
2165 assert_eq!(recorded.request_count(), 2);
2166 }
2167
2168 #[tokio::test]
2169 async fn prompt_request_stops_cleanly_on_empty_terminal_turn() {
2170 let first_call_usage = Usage {
2171 input_tokens: 1,
2172 output_tokens: 1,
2173 total_tokens: 2,
2174 cached_input_tokens: 0,
2175 cache_creation_input_tokens: 0,
2176 tool_use_prompt_tokens: 0,
2177 reasoning_tokens: 0,
2178 };
2179 let second_call_usage = Usage {
2180 input_tokens: 1,
2181 output_tokens: 1,
2182 total_tokens: 2,
2183 cached_input_tokens: 0,
2184 cache_creation_input_tokens: 0,
2185 tool_use_prompt_tokens: 0,
2186 reasoning_tokens: 0,
2187 };
2188 let model = MockCompletionModel::new([
2189 MockTurn::tool_call("tool_call_1", "add", json!({"x": 1, "y": 2}))
2190 .with_call_id("call_1")
2191 .with_usage(first_call_usage),
2192 MockTurn::text("").with_usage(second_call_usage),
2193 ]);
2194 let agent = AgentBuilder::new(model).tool(MockAddTool).build();
2195
2196 let response = agent
2197 .prompt("do tool work")
2198 .max_turns(3)
2199 .extended_details()
2200 .await
2201 .expect("empty terminal turn should not error");
2202
2203 assert!(response.output.is_empty());
2204 assert_eq!(
2205 response.usage,
2206 Usage {
2207 input_tokens: 2,
2208 output_tokens: 2,
2209 total_tokens: 4,
2210 cached_input_tokens: 0,
2211 cache_creation_input_tokens: 0,
2212 tool_use_prompt_tokens: 0,
2213 reasoning_tokens: 0,
2214 }
2215 );
2216 assert_eq!(
2217 response.completion_calls(),
2218 &[
2219 CompletionCall::new(0, first_call_usage),
2220 CompletionCall::new(1, second_call_usage)
2221 ]
2222 );
2223
2224 let history = response
2225 .messages
2226 .expect("extended response should include history");
2227 assert_eq!(history.len(), 3);
2228 assert!(matches!(
2229 history.first(),
2230 Some(Message::User { content })
2231 if matches!(
2232 content.first(),
2233 UserContent::Text(text) if text.text == "do tool work"
2234 )
2235 ));
2236 assert!(history.iter().any(|message| matches!(
2237 message,
2238 Message::Assistant { content, .. }
2239 if matches!(
2240 content.first(),
2241 AssistantContent::ToolCall(tool_call)
2242 if tool_call.id == "tool_call_1"
2243 && tool_call.call_id.as_deref() == Some("call_1")
2244 )
2245 )));
2246 assert!(history.iter().any(|message| matches!(
2247 message,
2248 Message::User { content }
2249 if matches!(
2250 content.first(),
2251 UserContent::ToolResult(tool_result)
2252 if tool_result.id == "tool_call_1"
2253 && tool_result.call_id.as_deref() == Some("call_1")
2254 )
2255 )));
2256 assert!(!history.iter().any(|message| matches!(
2257 message,
2258 Message::Assistant { content, .. }
2259 if content.iter().any(|item| matches!(
2260 item,
2261 AssistantContent::Text(text) if text.text.is_empty()
2262 ))
2263 )));
2264 let requests = agent.model.requests();
2265 assert_eq!(requests.len(), 2);
2266 validate_follow_up_tool_history(&requests[1]);
2267 }
2268
2269 #[tokio::test]
2270 async fn prompt_request_concatenates_text_blocks_without_inserted_newlines() {
2271 let model = MockCompletionModel::new([MockTurn::from_contents([
2272 AssistantContent::Text(Text::new("According to the document, ")),
2273 AssistantContent::Text(Text::new("the grass is green")),
2274 AssistantContent::Text(Text::new(" and the sky is blue.")),
2275 ])
2276 .expect("mock response should contain text blocks")]);
2277 let agent = AgentBuilder::new(model).build();
2278
2279 let response = agent
2280 .prompt("answer with cited spans")
2281 .await
2282 .expect("prompt should succeed");
2283
2284 assert_eq!(
2285 response,
2286 "According to the document, the grass is green and the sky is blue."
2287 );
2288 }
2289
2290 #[tokio::test]
2291 async fn prompt_request_preserves_metadata_only_text_turn_in_history() {
2292 let metadata = json!({
2293 "citations": [{
2294 "type": "web_search_result_location",
2295 "cited_text": "Claude Shannon was born in 1916.",
2296 "url": "https://example.com/shannon",
2297 "title": null,
2298 "encrypted_index": "encrypted-reference"
2299 }]
2300 });
2301 let model =
2302 MockCompletionModel::new([MockTurn::from_content(AssistantContent::Text(Text {
2303 text: String::new(),
2304 additional_params: Some(metadata.clone()),
2305 }))]);
2306 let agent = AgentBuilder::new(model).build();
2307
2308 let response = agent
2309 .prompt("answer with cited metadata")
2310 .extended_details()
2311 .await
2312 .expect("metadata-only text turn should succeed");
2313
2314 assert!(response.output.is_empty());
2315 let history = response
2316 .messages
2317 .expect("extended response should include history");
2318 assert!(history.iter().any(|message| matches!(
2319 message,
2320 Message::Assistant { content, .. }
2321 if matches!(
2322 content.first(),
2323 AssistantContent::Text(text)
2324 if text.text.is_empty()
2325 && text.additional_params.as_ref() == Some(&metadata)
2326 )
2327 )));
2328 }
2329
2330 use crate::memory::{ConversationMemory, InMemoryConversationMemory};
2333
2334 #[tokio::test]
2335 async fn memory_loads_into_request_history() {
2336 let memory = InMemoryConversationMemory::new();
2337 memory
2338 .append(
2339 "thread-1",
2340 vec![Message::user("hello"), Message::assistant("hi there")],
2341 )
2342 .await
2343 .unwrap();
2344
2345 let model = MockCompletionModel::text("ack");
2346 let recorded = model.clone();
2347
2348 let agent = AgentBuilder::new(model).memory(memory).build();
2349 let _ = agent
2350 .prompt("ping")
2351 .conversation("thread-1")
2352 .await
2353 .expect("prompt should succeed");
2354
2355 let received = recorded.requests()[0]
2356 .chat_history
2357 .iter()
2358 .cloned()
2359 .collect::<Vec<_>>();
2360 assert_eq!(
2361 received.len(),
2362 3,
2363 "loaded memory (2) + current prompt should appear in request: {received:?}"
2364 );
2365 }
2366
2367 #[tokio::test]
2368 async fn memory_appends_full_turn_after_success() {
2369 let memory = InMemoryConversationMemory::new();
2370 let model = MockCompletionModel::text("ack");
2371 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2372
2373 let _ = agent
2374 .prompt("hello")
2375 .conversation("t1")
2376 .await
2377 .expect("prompt should succeed");
2378
2379 let stored = memory.load("t1").await.unwrap();
2380 assert_eq!(stored.len(), 2, "user prompt + assistant response saved");
2381 }
2382
2383 #[tokio::test]
2384 async fn explicit_with_history_overrides_memory() {
2385 let memory = CountingMemory::default();
2386 memory
2387 .inner()
2388 .append("t1", vec![Message::user("from-memory")])
2389 .await
2390 .unwrap();
2391
2392 let model = MockCompletionModel::text("ack");
2393 let recorded = model.clone();
2394
2395 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2396 let _ = agent
2397 .prompt("hello")
2398 .conversation("t1")
2399 .history(vec![Message::user("from-caller")])
2400 .await
2401 .expect("prompt should succeed");
2402
2403 assert_eq!(memory.load_count(), 0, "load skipped");
2404 let appends = memory.append_count();
2405 assert_eq!(appends, 0, "append skipped");
2406
2407 let received = recorded.requests()[0]
2408 .chat_history
2409 .iter()
2410 .cloned()
2411 .collect::<Vec<_>>();
2412 assert_eq!(received.len(), 2, "caller history (1) + current prompt");
2413 assert!(matches!(
2414 received.first(),
2415 Some(Message::User { content })
2416 if matches!(content.first(), UserContent::Text(t) if t.text == "from-caller")
2417 ));
2418 }
2419
2420 #[tokio::test]
2421 async fn memory_unchanged_on_provider_error() {
2422 let memory = InMemoryConversationMemory::new();
2423 let model = MockCompletionModel::new([MockTurn::error("boom")]);
2424
2425 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2426 let result = agent.prompt("hello").conversation("t1").await;
2427 assert!(result.is_err());
2428
2429 let stored = memory.load("t1").await.unwrap();
2430 assert!(stored.is_empty(), "no append on error");
2431 }
2432
2433 #[tokio::test]
2434 async fn missing_conversation_id_behaves_as_no_memory() {
2435 let memory = CountingMemory::default();
2436 let model = MockCompletionModel::text("ack");
2437 let agent = AgentBuilder::new(model).memory(memory.clone()).build();
2438
2439 let _ = agent.prompt("hello").await.expect("prompt should succeed");
2440
2441 assert_eq!(memory.load_count(), 0);
2442 assert_eq!(memory.append_count(), 0);
2443 }
2444
2445 #[tokio::test]
2446 async fn default_conversation_id_is_used_when_none_per_request() {
2447 let memory = InMemoryConversationMemory::new();
2448 let model = MockCompletionModel::text("ack");
2449 let agent = AgentBuilder::new(model)
2450 .memory(memory.clone())
2451 .conversation("default-thread")
2452 .build();
2453
2454 let _ = agent.prompt("hello").await.expect("prompt should succeed");
2455 let stored = memory.load("default-thread").await.unwrap();
2456 assert_eq!(stored.len(), 2);
2457 }
2458
2459 #[tokio::test]
2460 async fn with_filter_truncates_loaded_history() {
2461 let memory = InMemoryConversationMemory::new()
2462 .with_filter(|msgs: Vec<Message>| msgs.into_iter().rev().take(2).rev().collect());
2463 memory
2464 .append(
2465 "t1",
2466 vec![
2467 Message::user("1"),
2468 Message::assistant("2"),
2469 Message::user("3"),
2470 Message::assistant("4"),
2471 ],
2472 )
2473 .await
2474 .unwrap();
2475
2476 let model = MockCompletionModel::text("ack");
2477 let recorded = model.clone();
2478 let agent = AgentBuilder::new(model).memory(memory).build();
2479
2480 let _ = agent
2481 .prompt("ping")
2482 .conversation("t1")
2483 .await
2484 .expect("prompt should succeed");
2485
2486 let received = recorded.requests()[0]
2487 .chat_history
2488 .iter()
2489 .cloned()
2490 .collect::<Vec<_>>();
2491 assert_eq!(
2492 received.len(),
2493 3,
2494 "window-truncated history (2) + current prompt"
2495 );
2496 }
2497
2498 #[tokio::test]
2499 async fn without_memory_disables_for_request() {
2500 let memory = CountingMemory::default();
2501 let model = MockCompletionModel::text("ack");
2502 let agent = AgentBuilder::new(model)
2503 .memory(memory.clone())
2504 .conversation("t1")
2505 .build();
2506
2507 let _ = agent
2508 .prompt("hello")
2509 .without_memory()
2510 .await
2511 .expect("prompt should succeed");
2512
2513 assert_eq!(memory.load_count(), 0);
2514 assert_eq!(memory.append_count(), 0);
2515 }
2516
2517 #[tokio::test]
2518 async fn memory_load_error_surfaces_as_prompt_error() {
2519 let model = MockCompletionModel::text("ack");
2520 let agent = AgentBuilder::new(model)
2521 .memory(FailingMemory::default())
2522 .build();
2523 let result = agent.prompt("hello").conversation("t1").await;
2524
2525 match result {
2526 Err(PromptError::CompletionError(CompletionError::RequestError(err))) => {
2527 let msg = format!("{err}");
2528 assert!(msg.contains("load boom"), "got: {msg}");
2529 }
2530 other => panic!("expected PromptError::CompletionError(RequestError), got {other:?}"),
2531 }
2532 }
2533
2534 #[tokio::test]
2535 async fn memory_append_error_does_not_drop_response() {
2536 let model = MockCompletionModel::text("ack");
2537 let agent = AgentBuilder::new(model)
2538 .memory(AppendFailingMemory::default())
2539 .build();
2540 let response: String = agent
2541 .prompt("hello")
2542 .conversation("t1")
2543 .await
2544 .expect("append failure must not block successful completion");
2545
2546 assert!(!response.is_empty());
2547 }
2548}