Skip to main content

rig_agent/agent/prompt_request/
mod.rs

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
19/// The provider-neutral identity carrier, re-exported from rig-core so agent
20/// callers name one type across core responses, stream terminals, completion
21/// calls, and hook events.
22pub use rig_core::completion::ResponseIdentity;
23
24/// Generate the request-builder setters that forward verbatim to an inner
25/// receiver — `AgentRunner` for the blocking builder, the wrapped
26/// `PromptRequest` for the typed builder, and the `AgentRunner` for the
27/// streaming builder. Only the setters whose signature *and* documentation are
28/// identical across all three builders live here; `max_turns`, `add_hook`, and
29/// `tool_concurrency`, whose docs are builder-specific, stay hand-written (the
30/// blocking builders share `tool_concurrency` via [`forward_tool_concurrency`]).
31/// `$recv` is the field name to delegate through (`runner` or `inner`).
32macro_rules! forward_prompt_setters {
33    ($recv:ident) => {
34        /// Attach a per-call [`ToolContext`] for this request.
35        ///
36        /// Every tool the agent executes during this request can read the
37        /// caller-provided values (auth tokens, session IDs, conversation state, …)
38        /// through the tool's [`ToolContext`](crate::tool::ToolContext),
39        /// without the model ever seeing them.
40        pub fn tool_context(mut self, context: ToolContext) -> Self {
41            self.$recv = self.$recv.tool_context(context);
42            self
43        }
44
45        /// Add chat history to the prompt request.
46        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        /// Override the agent preamble for this request.
56        pub fn preamble(mut self, preamble: impl Into<String>) -> Self {
57            self.$recv = self.$recv.preamble(preamble);
58            self
59        }
60
61        /// Remove the agent's configured preamble for this request.
62        pub fn without_preamble(mut self) -> Self {
63            self.$recv = self.$recv.without_preamble();
64            self
65        }
66
67        /// Append one static context document for this request.
68        pub fn document(mut self, document: crate::completion::Document) -> Self {
69            self.$recv = self.$recv.document(document);
70            self
71        }
72
73        /// Append static context documents for this request.
74        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        /// Override the model temperature for this request.
83        pub fn temperature(mut self, temperature: f64) -> Self {
84            self.$recv = self.$recv.temperature(temperature);
85            self
86        }
87
88        /// Remove the agent's configured temperature for this request.
89        pub fn without_temperature(mut self) -> Self {
90            self.$recv = self.$recv.without_temperature();
91            self
92        }
93
94        /// Override the maximum completion token count for this request.
95        pub fn max_tokens(mut self, max_tokens: u64) -> Self {
96            self.$recv = self.$recv.max_tokens(max_tokens);
97            self
98        }
99
100        /// Remove the agent's configured maximum token count for this request.
101        pub fn without_max_tokens(mut self) -> Self {
102            self.$recv = self.$recv.without_max_tokens();
103            self
104        }
105
106        /// Shallow-merge object fields into the provider-specific parameters
107        /// for this request. Later fields win.
108        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        /// Replace all provider-specific parameters for this request.
117        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        /// Remove the agent's configured provider-specific parameters for this request.
123        pub fn without_additional_params(mut self) -> Self {
124            self.$recv = self.$recv.without_additional_params();
125            self
126        }
127
128        /// Override the tool-choice policy for this request.
129        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        /// Remove the agent's configured tool-choice policy for this request.
135        pub fn without_tool_choice(mut self) -> Self {
136            self.$recv = self.$recv.without_tool_choice();
137            self
138        }
139
140        /// Opt in or out of recording sensitive request, response, and tool
141        /// content on GenAI telemetry spans for this request.
142        ///
143        /// Defaults to the agent's setting, which defaults to `false`. Enabling
144        /// this can expose prompts, retrieved context, tool results, model
145        /// responses, and other sensitive or high-cardinality data through
146        /// OpenTelemetry span attributes, which can increase observability
147        /// backend storage and query costs. Only enable it when content
148        /// telemetry is acceptable for this request. Structural metadata and
149        /// token usage remain available when disabled.
150        pub fn record_content_telemetry(mut self, enabled: bool) -> Self {
151            self.$recv = self.$recv.record_content_telemetry(enabled);
152            self
153        }
154
155        /// Set the conversation id used to load and persist memory for this request.
156        ///
157        /// Overrides any default conversation id set on the agent. If memory is not
158        /// configured on the agent, this has no effect.
159        pub fn conversation(mut self, id: impl Into<String>) -> Self {
160            self.$recv = self.$recv.conversation(id);
161            self
162        }
163
164        /// Disable conversation memory for this request.
165        ///
166        /// History will neither be loaded from nor saved to the agent's memory backend.
167        pub fn without_memory(mut self) -> Self {
168            self.$recv = self.$recv.without_memory();
169            self
170        }
171
172        /// Set the retry budget for invalid tool-call recovery.
173        ///
174        /// Invalid tool-call retries also consume the total model-call budget.
175        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        /// Set the default model candidate for this run.
181        ///
182        /// This does not suppress registered model-selection hooks, which may
183        /// replace this candidate before each model call (including retries).
184        pub fn using_model(mut self, model: $crate::agent::ModelHandle) -> Self {
185            self.$recv = self.$recv.using_model(model);
186            self
187        }
188
189        /// Erase and set a typed default model for this run.
190        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
201/// Generate the `tool_concurrency` setter for the blocking builders, whose doc
202/// is identical to each other but differs from the streaming builder's (the
203/// streaming version documents how tool items are ordered in the emitted
204/// stream). `$recv` is the field name to delegate through (`runner` or `inner`).
205macro_rules! forward_tool_concurrency {
206    ($recv:ident) => {
207        /// Execute up to `concurrency` of a turn's tool calls at once.
208        ///
209        /// See [`AgentRunner::tool_concurrency`] for ordering guarantees: the tool
210        /// batch commits and surfaces atomically, so persisted history and streamed
211        /// tool results are both in tool-call order (results are surfaced only after
212        /// the whole batch settles successfully).
213        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
227/// A builder for creating prompt requests with customizable options.
228/// Uses generics to track which options have been set during the build process.
229///
230/// When the agent has no configured `default_max_turns`, the implicit budget is
231/// one model call. Use [`.max_turns()`](Self::max_turns) to override the agent's
232/// configured or implicit budget; a tool call followed by a model-authored final
233/// answer generally requires at least two model calls.
234pub struct PromptRequest<S>
235where
236    S: PromptType,
237{
238    /// The hook-aware driver this request configures and runs.
239    pub(crate) runner: AgentRunner,
240    /// Phantom data to track the type of the request (Standard vs Extended).
241    state: PhantomData<S>,
242}
243
244impl PromptRequest<Standard> {
245    /// Create a new PromptRequest from an agent, cloning the agent's data and
246    /// default hook stack.
247    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    /// Enable returning extended details for responses (includes aggregated token usage
260    /// and the full message history accumulated during the agent loop).
261    ///
262    /// Note: This changes the type of the response from `.send` to return a `PromptResponse` struct
263    /// instead of a simple `String`. This is useful for tracking token usage across multiple turns
264    /// of conversation and inspecting the full message exchange.
265    pub fn extended_details(self) -> PromptRequest<Extended> {
266        PromptRequest {
267            runner: self.runner,
268            state: PhantomData,
269        }
270    }
271
272    /// Set the total model-call budget, including the initial call and every
273    /// retry or continuation. Zero emits no model calls; one permits only the
274    /// initial call. Exceeding the budget returns
275    /// [`crate::completion::PromptError::MaxTurnsError`].
276    pub fn max_turns(mut self, max_turns: usize) -> Self {
277        self.runner = self.runner.max_turns(max_turns);
278        self
279    }
280
281    /// Append a hook for this request (on top of any the agent already carries).
282    /// Hooks run in registration order; how their results compose is
283    /// event-dependent (model selections and `ToolCall`/`ToolResult` rewrites
284    /// chain, `CompletionCall` request patches accumulate and merge, while
285    /// model-turn steering and observe-only/recovery events use
286    /// first-non-`Continue`-wins). See the [`hook`](crate::agent::hook) module
287    /// docs.
288    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
300/// Due to: [RFC 2515](https://github.com/rust-lang/rust/issues/63063), we have to use a `BoxFuture`
301///  for the `IntoFuture` implementation. In the future, we should be able to use `impl Future<...>`
302///  directly via the associated type.
303impl 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/// Details for one successfully completed completion request made by an agent run.
328// No longer `Copy`: the identity fields carry owned strings. No longer `Eq`:
329// `raw` is a `serde_json::Value`, which is `PartialEq` but not `Eq` (floats).
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331pub struct CompletionCall {
332    /// Zero-based index of the completion request within this agent run.
333    pub call_index: usize,
334    /// Token usage reported for this completion request.
335    ///
336    /// Zero-valued usage is [`Usage`]'s documented sentinel for missing
337    /// provider usage metrics; rig does not distinguish "reported all zeros"
338    /// from "unreported".
339    #[serde(default, deserialize_with = "usage_null_as_default")]
340    pub usage: Usage,
341    /// Provider-assigned assistant message ID for this call, when reported.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub message_id: Option<String>,
344    /// Provider-assigned response-scoped ID for this call, when reported.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub response_id: Option<String>,
347    /// The provider's transport request id for this call (HTTP response
348    /// header, e.g. Anthropic `request-id`) — the id provider support asks
349    /// for. `None` means the provider did not report one, never an error.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub provider_request_id: Option<String>,
352    /// Why the model stopped generating on this call, when the provider
353    /// reported it. `None` means the provider reported no reason.
354    ///
355    /// Recorded **per call** rather than once per run: a multi-turn run makes N
356    /// completion requests, each with its own terminal reason, and collapsing
357    /// them to a single run-level value would lose exactly the information that
358    /// makes a truncated turn diagnosable — which turn hit the limit. A caller
359    /// that wants the run's last reason reads it off the final entry.
360    ///
361    /// This is the field whose absence hid rig#2322: the provider layer carried
362    /// [`FinishReason::Length`] on the stream's terminal record, but the agent
363    /// assembler dropped it, so a turn truncated at the output-token limit was
364    /// indistinguishable from a turn that simply had nothing to say.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub finish_reason: Option<FinishReason>,
367    /// The provider's own response for this call — see
368    /// `CompletionResponse::raw` for the exact meaning of the payload. Every
369    /// provider seam populates it; `Value::Null` only when the call's response
370    /// was built without a provider behind it (a hand-constructed model, a
371    /// record persisted before the field, or a hand-driven `AgentRun` that
372    /// recorded a streamed call with no terminal record — the runner itself
373    /// rejects such a stream as truncated before recording anything).
374    ///
375    /// Recorded **per call**, like [`Self::finish_reason`]: on a multi-turn
376    /// run each entry carries its own attempt's response, never a previous
377    /// attempt's, and on a retried turn the recorded call carries the retried
378    /// attempt's own.
379    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
380    pub raw: serde_json::Value,
381}
382
383impl CompletionCall {
384    /// Create details for one completion request in an agent run; identity
385    /// metadata starts unset and is attached with [`Self::with_identity`].
386    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    /// Attach the provider's own response this call's attempt produced.
399    pub fn with_raw(mut self, raw: serde_json::Value) -> Self {
400        self.raw = raw;
401        self
402    }
403
404    /// Attach the response identity metadata this call's attempt reported.
405    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    /// Attach the terminal finish reason this call's attempt reported.
413    ///
414    /// Kept separate from [`Self::with_identity`] because a finish reason is
415    /// not identity: [`ResponseIdentity`] answers "which response was this",
416    /// while this answers "why did it stop".
417    pub fn with_finish_reason(mut self, finish_reason: Option<FinishReason>) -> Self {
418        self.finish_reason = finish_reason;
419        self
420    }
421
422    /// This call's identity metadata as one [`ResponseIdentity`] carrier.
423    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
432/// Tolerate `null` usage from data serialized before rig dropped the
433/// `Option<Usage>` encoding of missing provider usage metrics.
434///
435/// This tolerance requires a self-describing format such as JSON; data
436/// serialized with non-self-describing formats (e.g. bincode) from before the
437/// change cannot round-trip.
438fn 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/// The result of an agent run, returned by **both** the blocking
446/// ([`PromptRequest`]) and streaming ([`StreamingPromptRequest`]) surfaces so a
447/// call site reads identically whether it used `.prompt()` or `.stream_prompt()`.
448///
449/// On the streaming surface this is the payload of the terminal
450/// [`MultiTurnStreamItem::FinalResponse`] item.
451///
452/// [`StreamingPromptRequest`]: crate::agent::StreamingPromptRequest
453/// [`MultiTurnStreamItem::FinalResponse`]: crate::agent::MultiTurnStreamItem::FinalResponse
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct PromptResponse {
456    /// Concatenated assistant text for the final turn.
457    pub output: String,
458    /// Aggregated token usage across the whole run.
459    pub usage: Usage,
460    /// Successfully completed completion requests made by this agent run.
461    ///
462    /// `usage` remains the aggregate across the whole run. Use the last
463    /// entry's usage to inspect the final completion request's prompt/context
464    /// length. Zero-valued entry usage means the provider reported no usage
465    /// metrics for that request.
466    #[serde(default, skip_serializing_if = "Vec::is_empty")]
467    pub completion_calls: Vec<CompletionCall>,
468    /// Accumulated message history for the run (the run's persisted transcript),
469    /// unless memory/history bookkeeping was disabled for the request.
470    pub messages: Option<Vec<Message>>,
471    /// Structured assistant content for the final turn.
472    ///
473    /// Where [`output`](Self::output) is the concatenated text, this preserves
474    /// the individual content parts (text, reasoning, images, …).
475    pub content: Vec<AssistantContent>,
476    /// Number of synthetic output-tool calls in the turn that finalized this
477    /// response. Kept crate-private because it is runner bookkeeping rather
478    /// than provider-facing response content.
479    #[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    /// An empty run result (empty output, zero usage, no history).
503    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    /// Attach completion call details to this response.
513    pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
514        self.completion_calls = completion_calls;
515        self
516    }
517
518    /// Set the structured assistant content for the final turn.
519    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    /// The concatenated assistant text for the final turn.
534    pub fn output(&self) -> &str {
535        &self.output
536    }
537
538    /// Aggregated token usage across the whole run.
539    pub fn usage(&self) -> Usage {
540        self.usage
541    }
542
543    /// The run's accumulated message history, if tracked.
544    pub fn messages(&self) -> Option<&[Message]> {
545        self.messages.as_deref()
546    }
547
548    /// The structured assistant content for the final turn.
549    pub fn content(&self) -> &[AssistantContent] {
550        &self.content
551    }
552
553    /// Returns successfully completed completion requests made by this agent run.
554    ///
555    /// Zero-valued entry usage means the provider reported no usage metrics
556    /// for that request.
557    pub fn completion_calls(&self) -> &[CompletionCall] {
558        &self.completion_calls
559    }
560
561    /// Number of completion requests this agent run made.
562    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    /// Successfully completed completion requests made by this agent run.
572    ///
573    /// `usage` remains the aggregate across the whole run. Use the last
574    /// entry's usage to inspect the final completion request's prompt/context
575    /// length. Zero-valued entry usage means the provider reported no usage
576    /// metrics for that request.
577    #[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    /// Attach completion call details to this response.
591    pub fn with_completion_calls(mut self, completion_calls: Vec<CompletionCall>) -> Self {
592        self.completion_calls = completion_calls;
593        self
594    }
595
596    /// Returns successfully completed completion requests made by this agent run.
597    ///
598    /// Zero-valued entry usage means the provider reported no usage metrics
599    /// for that request.
600    pub fn completion_calls(&self) -> &[CompletionCall] {
601        &self.completion_calls
602    }
603
604    /// Number of completion requests this agent run made.
605    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
613/// Combine input history with new messages for building completion requests.
614pub(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
622/// Build the full history for error reporting (input + new messages).
623pub(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
631/// Wrap already-shaped tool-result content for the model (see
632/// [`tool_result_output`] / [`tool_result_message`]).
633fn tool_result_with(
634    call: ToolCallId,
635    provider: Option<ProviderCallId>,
636    name: String,
637    content: Vec<ToolResultContent>,
638) -> UserContent {
639    // The *executed* tool's name travels as data on the result: several
640    // wires require it on replay (Gemini `functionResponse.name`, Ollama
641    // tool messages), and an identifier is not a name.
642    UserContent::tool_result_for(call, provider, name, content)
643}
644
645/// Shape a canonical real tool output as a tool result without reparsing text.
646pub(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
655/// Shape a **synthetic message** (a hook skip reason, recovery feedback, or a
656/// "not executed" notice) as a tool result. Emitted **verbatim as text** and
657/// never re-parsed as structured tool output, so a JSON-shaped message is not
658/// silently reinterpreted as an image/multimodal result. Used identically by the
659/// blocking and streaming drivers so synthetic results match across both.
660pub(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    // Selecting the invalid call by id is correct by construction:
675    // `ToolCallId` is unique and non-empty (minted at the provider boundary
676    // when the wire issued none), so id-less wires can no longer collapse
677    // every peer onto the first match arm.
678    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
704/// Whether an assistant turn carried nothing the caller should see.
705///
706/// Two shapes mean the same thing, and both must be recognised:
707///
708/// - **Zero parts.** A turn that produced no text and no tool call is an
709///   empty list — the shape the streaming path produces (its assembler
710///   filters empty text deltas out of the canonical order).
711/// - **One empty, unannotated text block.** A blocking wire can deliver an
712///   assistant message whose only part is an empty text block; it carries
713///   nothing, and the agent curates it out of history exactly as it curates
714///   a zero-part turn. The annotation guard is load-bearing: an *annotated*
715///   empty text block carries data and must not read as empty. Annotation is
716///   a plain `is_some()`: [`rig_core::message::AdditionalParams`] is
717///   non-empty by construction, so `Some` always carries data, live and
718///   restored alike (pinned by
719///   `empty_turn_classification_survives_a_serde_round_trip`).
720///
721/// This runs on turns flowing through the agent loop only. Caller-supplied
722/// `chat_history` is never filtered: an empty text block you replay goes to
723/// the wire as-is.
724pub(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
737/// Whether a turn delivered **no answer**: no tool call, and no non-empty text
738/// block.
739///
740/// Deliberately *not* [`is_empty_assistant_turn`], which answers a different
741/// question — "does this turn belong in history". They diverge on the shapes
742/// that are **worth recording yet answer nothing**, of which there are two:
743///
744/// 1. a turn carrying only [`AssistantContent::Reasoning`] — the reasoning is
745///    real content worth replaying, but it is not an answer;
746/// 2. a turn carrying only an **empty text block with `additional_params`** —
747///    the annotation (citations, encrypted reasoning references, and other
748///    provider metadata some wires require on replay) is worth recording, but
749///    the caller still receives no text.
750///
751/// Metadata-only text therefore does **not** count as an answer. That follows
752/// from what the caller actually gets: [`assistant_text_from_choice`]
753/// concatenates `text.text` alone, so such a turn yields `""` — the annotation
754/// is metadata *about* an answer, never the answer itself.
755///
756/// Reasoning is not an answer. It is the model's scratch work, it is often not
757/// even replayable across turns, and a caller asked a question rather than for
758/// the thinking. Treating it as output is how a thinking model that burned its
759/// whole budget mid-thought used to report success with an empty string
760/// (rig#2322): Gemini counts thinking tokens against `maxOutputTokens`, so a
761/// truncated thinking turn *typically* carries reasoning and no text — the
762/// common case, not a corner one.
763///
764/// Tool calls count as delivered: they are an answer in progress, and a
765/// truncated tool-call turn must still route to execution. So do images —
766/// ten providers emit assistant images, and an image *is* the answer for an
767/// image-generation turn.
768///
769/// The match is **exhaustive on purpose**: no `_` arm. Every content variant
770/// must be classified explicitly, so adding one to [`AssistantContent`] breaks
771/// this build and forces a decision instead of silently inheriting a default.
772/// The first version of this predicate had a `_ => false` catch-all and so
773/// classified image-only turns as "no answer" — a truncated image-generation
774/// turn would have errored despite delivering an image, which matters because
775/// image tokens count against the same output budget.
776pub(crate) fn turn_delivered_no_answer(choice: &[AssistantContent]) -> bool {
777    !choice.iter().any(|content| match content {
778        // Real text is an answer; an empty block delivers nothing.
779        AssistantContent::Text(text) => !text.text.is_empty(),
780        AssistantContent::ToolCall(_) => true,
781        AssistantContent::Image(_) => true,
782        // The one exclusion: scratch work, not an answer.
783        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
803// ================================================================
804// TypedPromptRequest - for structured output with automatic deserialization
805// ================================================================
806
807use crate::completion::StructuredOutputError;
808use schemars::{JsonSchema, schema_for};
809use serde::de::DeserializeOwned;
810
811/// A builder for creating typed prompt requests that return deserialized structured output.
812///
813/// This struct wraps a standard `PromptRequest` and adds:
814/// - Automatic JSON schema generation from the target type `T`
815/// - Automatic deserialization of the response into `T`
816///
817/// The type parameter `S` represents the state of the request (Standard or Extended).
818/// Use `.extended_details()` to transition to Extended state for usage tracking.
819///
820/// # Example
821/// ```rust,ignore
822/// let forecast: WeatherForecast = agent
823///     .prompt_typed("What's the weather in NYC?")
824///     .max_turns(3)
825///     .await?;
826/// ```
827pub 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    /// Create a new TypedPromptRequest from an agent.
841    ///
842    /// This automatically sets the output schema based on the type parameter `T`.
843    pub fn from_agent(agent: &Agent, prompt: impl Into<Message>) -> Self {
844        let mut inner = PromptRequest::from_agent(agent, prompt);
845        // Override the output schema with the schema for T
846        inner.runner.config.output_schema = Some(schema_for!(T));
847        // Typed prompts deserialize the model's final string, so they pin
848        // `Native` structured output to keep the typed API's behavior unchanged
849        // across all providers (#1928). Routing the typed path through `Tool`
850        // output mode for tool-using agents on non-composing providers is a
851        // follow-up; use the untyped `output_schema`/`output_mode` API for
852        // tool-composing structured output today.
853        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    /// Enable returning extended details for responses (includes aggregated token usage).
867    ///
868    /// Note: This changes the type of the response from `.send()` to return a `TypedPromptResponse<T>` struct
869    /// instead of just `T`. This is useful for tracking token usage across multiple turns
870    /// of conversation.
871    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    /// Set the total model-call budget, including the initial call and every
879    /// retry or continuation. Zero emits no model calls; one permits only the
880    /// initial call. Exceeding the budget returns a
881    /// [`StructuredOutputError::PromptError`] wrapping a `MaxTurnsError`.
882    pub fn max_turns(mut self, max_turns: usize) -> Self {
883        self.inner = self.inner.max_turns(max_turns);
884        self
885    }
886
887    /// Append a hook to this request's hook stack (on top of any the agent
888    /// already carries).
889    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
901/// Deserialize a typed structured response from the model's final text.
902///
903/// Tries a direct parse first (the common path — native and tool-call output is
904/// already clean JSON), then falls back to the first balanced JSON value in the
905/// text so prose or markdown code fences around the JSON don't break weaker
906/// `Prompted`/best-effort output (#1928).
907fn 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    /// Send the typed prompt request and deserialize the response.
928    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    /// Send the typed prompt request with extended details and deserialize the response.
945    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    /// rig#2322 — the **blocking** surface enforces the same truncation
1019    /// contract as the streamed one.
1020    ///
1021    /// The premise of the whole fix is that the two surfaces disagreeing is
1022    /// what let truncation surface as a blank answer, yet every other guard
1023    /// test drives `stream_prompt`. Until `MockTurn::with_finish_reason`
1024    /// existed the blocking mock could not report a reason at all, so
1025    /// `runner.rs`'s `.with_finish_reason(resp.finish_reason())` — and its
1026    /// propagation through `model_response` → `record_completion_call` → this
1027    /// guard — was never exercised. Deleting that one line failed nothing.
1028    #[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    /// rig#2322 — blocking counterpart of the reasoning-only case: the shape
1049    /// that motivated the predicate fix must be caught on both surfaces.
1050    #[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    /// rig#2322 — and the blocking surface keeps a truncated turn that *did*
1069    /// answer, with the reason reaching `completion_calls`.
1070    ///
1071    /// This is the half that proves the blocking plumbing carries the reason
1072    /// rather than merely erroring: the value has to survive into the returned
1073    /// `PromptResponse`.
1074    #[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    /// rig#2322 follow-up — every `AssistantContent` variant's answer
1100    /// classification, pinned one variant at a time.
1101    ///
1102    /// A unit test rather than a cassette because this is a pure
1103    /// classification decision over a rig-owned enum: no provider traffic is
1104    /// involved, and the failure it guards against (a new variant silently
1105    /// inheriting the wrong bucket) is invisible at the wire level.
1106    ///
1107    /// The predicate originally used a `_ => false` catch-all, which made
1108    /// image-only turns read as "no answer" — so a truncated image-generation
1109    /// turn would have errored despite delivering an image. The match is now
1110    /// exhaustive; this test pins each arm so a reclassification is deliberate.
1111    #[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        // Delivered an answer.
1125        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        // Delivered nothing.
1134        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        // Mixed: one real item is enough.
1142        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    /// rig#2322 follow-up — the two predicates diverge on exactly the shapes
1157    /// that are **worth recording yet answer nothing**, and that is intentional.
1158    ///
1159    /// `is_empty_assistant_turn` governs the history push ("does this belong in
1160    /// the transcript"); `turn_delivered_no_answer` governs the truncation
1161    /// guard ("did this answer the question"). Two shapes disagree, and for the
1162    /// same reason: reasoning-only, and an empty text block carrying
1163    /// `additional_params`. Both are worth recording; neither delivers text.
1164    ///
1165    /// An earlier version of this test claimed the divergence was reasoning-only
1166    /// and checked just three agreeing shapes — none of them annotated — so the
1167    /// second case went unnoticed. The agreeing set is now enumerated
1168    /// explicitly alongside it.
1169    #[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        // Divergent: recordable, but no answer was delivered.
1183        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        // Agreeing: everything else.
1195        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    /// rig#2322 follow-up — metadata-only text is **not** an answer, stated as
1218    /// its own decision rather than left implied by the predicate's code.
1219    ///
1220    /// A text block with `additional_params` and no text carries provider
1221    /// metadata (citations, encrypted reasoning references) that is worth
1222    /// keeping in history, but `assistant_text_from_choice` concatenates
1223    /// `text.text` alone — so the caller receives `""`. Nothing was answered,
1224    /// and a turn truncated in that state is a failed turn.
1225    ///
1226    /// If this is ever reclassified, the run-level consequence is the point to
1227    /// weigh: it decides whether a truncated annotation-only turn errors or
1228    /// silently returns an empty string.
1229    #[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        // The annotation does not suppress a real answer beside it.
1249        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        // Clean JSON (native / output-tool path).
1273        assert_eq!(
1274            super::deserialize_structured_output::<TypedAnswer>(r#"{"value":"x"}"#).unwrap(),
1275            TypedAnswer { value: "x".into() }
1276        );
1277        // Markdown-fenced JSON (weak Prompted-mode models).
1278        assert_eq!(
1279            super::deserialize_structured_output::<TypedAnswer>("```json\n{\"value\":\"y\"}\n```")
1280                .unwrap(),
1281            TypedAnswer { value: "y".into() }
1282        );
1283        // Prose around the JSON object.
1284        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        // No JSON at all still errors.
1292        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        // Unreported usage serializes as a plain zero-valued object: zero is
1517        // Usage's documented sentinel for missing provider metrics, so there
1518        // is no null encoding to keep in sync.
1519        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        // A suspended run restored from JSON must classify its empty-text
1576        // turn exactly like the live run did, whatever spelling of "no
1577        // extras" the JSON carries, and an *annotated* empty block must
1578        // still read as content either way. The serde canonicalization
1579        // mechanics behind this (`{}`/`null` decode to `None`, empty params
1580        // never serialize) are pinned where they live, by rig-core's
1581        // `empty_params_canonicalize_to_none_in_both_serde_directions` —
1582        // this test asserts classification only.
1583        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        // An explicit `{}` or `null` in the JSON — the shape a mechanical
1595        // migration script writes — classifies exactly like an absent field.
1596        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        // The old uncanonicalized-`Some({})` hazard is unrepresentable:
1605        // `AdditionalParams` has no empty value, so the only way to spell
1606        // "no extras" in memory is `None` and live/restored classification
1607        // agree by construction.
1608        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        // Pins `CompletionCall.usage`'s null tolerance: `"usage": null` (the
1634        // pre-monoid Option encoding) must map to zero-valued usage. The
1635        // fixture otherwise uses the current shape — `content` is a required
1636        // field since the missing-`content` reconstruction was dropped.
1637        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        // `content` uses the tagged shape — assistant content is tagged like
1649        // user content (see `the_type_key_is_the_tag_and_the_untagged_shape_does_not_load`).
1650        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        // Assistant content is tagged like user content: `"type"` is consumed
1659        // as the discriminant, never captured into `additional_params`. And
1660        // there is deliberately no untagged fallback — the bare shape 0.41
1661        // serialized fails to deserialize (MIGRATING carries the recipe),
1662        // pinned here so removing the tag requirement is a visible decision,
1663        // not an accident.
1664        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        // An explicitly-set `content` (e.g. the streaming surface's structured
1680        // final turn) must survive a serialize/deserialize round-trip intact —
1681        // `content` and `output` are independent fields.
1682        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        // The stored content is "structured" — distinct from `output` — so the
1695        // round trip demonstrably carried `content` itself rather than anything
1696        // derived from `output`. (Compare the text directly to sidestep the
1697        // `Text::additional_params` serde round-trip asymmetry.)
1698        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        // `content` is a required, bare list in both serde directions — the
1707        // pre-`content` reconstruction (and the shadow repr that carried it)
1708        // is gone, so serialize and deserialize agree by construction. Pin
1709        // the shape: `content` present, `completion_calls` omitted only when
1710        // empty, and the value round-trips.
1711        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        // The omission direction of `completion_calls`' skip-when-empty:
1728        // an empty list serializes without the key (the shadow-era wire
1729        // shape), and the keyless JSON still deserializes.
1730        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        // The wire issued "tool_call_1" (adopted as rig's durable id) and the
1809        // provider-specific correlator was overridden to "call_1"; the result
1810        // answers the durable id and echoes the provider correlator.
1811        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    /// The invalid-call retry transcript pairs 1:1 by construction: every tool
1853    /// call in the assistant turn carries a unique non-empty id (minted at the
1854    /// provider boundary when the wire issued none), and the retry results
1855    /// answer exactly those ids.
1856    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    /// The motivating use-case: a `ToolContext` set on the prompt request is
1935    /// threaded all the way to the tool the agent loop executes.
1936    #[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    /// Context values persist for the whole run, across *multiple* tool-call rounds
1960    /// (the headline value prop). The model calls the probe in two consecutive
1961    /// rounds; both must observe the same injected value, not just the first.
1962    #[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    /// Without a context, the same tool runs with an empty one (no panic, no
1990    /// stale value) — the backward-compatible default path.
1991    #[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        // The single call path receives an empty context and observes no session.
2008        assert_eq!(probe.observed().as_deref(), Some("no-session"));
2009    }
2010
2011    /// Direct typed calls use the same context contract as dispatched calls.
2012    #[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    // ----- Conversation memory integration tests -----
2914
2915    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        // A tool round-trip is two model calls (tool call -> final text) but one
3011        // run: the committed turn must be appended to memory exactly once, not
3012        // once per model call.
3013        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        // user prompt + assistant tool call + tool result + final assistant text.
3039        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        // With pre-loaded history, a run must append only the new turn's
3059        // messages, never re-append the loaded history (which would duplicate
3060        // it). Pre-load directly through `inner()` so it does not count as an
3061        // append by the run.
3062        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        // preloaded [old-q, old-a] + new [new-q, new-a]; re-appending the loaded
3085        // history would instead make this 6.
3086        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        // A run stopped by a hook before it completes must not append.
3104        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        // The committed history of a tool round-trip must be a well-formed
3133        // role sequence: it starts with a user message, never commits two
3134        // consecutive assistant messages, and pairs each assistant tool call
3135        // with a following user tool-result message.
3136        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        // Each assistant turn carrying a tool call is followed by a user
3167        // tool-result message.
3168        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    /// Serde compatibility (rig#2265): run records persisted before the
3299    /// identity fields existed still load, with every identity field `None`.
3300    #[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    /// And a populated record round-trips the identity losslessly.
3313    #[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}