Skip to main content

agent_sdk_foundation/
llm.rs

1//! LLM message and chat data types.
2//!
3//! These are the wire-format types shared between the runtime, providers,
4//! and the server.  The module intentionally contains **no** async traits
5//! or runtime-specific logic so it can be depended on from thin crates.
6
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11// ── Thinking ──────────────────────────────────────────────────────────
12
13/// The mode of extended thinking.
14#[derive(Debug, Clone)]
15pub enum ThinkingMode {
16    /// Explicitly enabled with a token budget.
17    Enabled { budget_tokens: u32 },
18    /// Adaptive thinking — the model decides how much to think.
19    Adaptive,
20    /// Provider-default thinking: no explicit budget, not adaptive. An
21    /// effort level can still be sent alongside it.
22    Default,
23}
24
25/// How thinking content is returned in responses.
26///
27/// The Anthropic API accepts exactly these two values; the per-model
28/// default differs (`Omitted` on Fable 5 / Sonnet 5 / Opus 4.7+,
29/// `Summarized` on the 4.6 generation), so the SDK always sends one.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum ThinkingDisplay {
33    /// Thinking blocks carry a readable summary of the reasoning.
34    Summarized,
35    /// Thinking blocks arrive with an empty `thinking` field; the
36    /// encrypted `signature` still carries multi-turn continuity.
37    Omitted,
38}
39
40/// Effort level for adaptive thinking via `output_config`.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Effort {
44    Low,
45    Medium,
46    High,
47    XHigh,
48    Max,
49}
50
51/// Configuration for extended thinking.
52///
53/// When enabled, the model will show its reasoning process before
54/// generating the final response.
55#[derive(Debug, Clone)]
56pub struct ThinkingConfig {
57    /// Which thinking mode to use.
58    pub mode: ThinkingMode,
59    /// Optional effort level (sent via `output_config`).
60    pub effort: Option<Effort>,
61    /// How thinking content is returned.
62    pub display: ThinkingDisplay,
63}
64
65impl ThinkingConfig {
66    /// Default budget: 10,000 tokens.
67    ///
68    /// This provides enough capacity for meaningful reasoning on most tasks
69    /// while keeping costs reasonable. Increase for complex multi-step problems.
70    pub const DEFAULT_BUDGET_TOKENS: u32 = 10_000;
71
72    /// Minimum budget required by the Anthropic API.
73    pub const MIN_BUDGET_TOKENS: u32 = 1_024;
74
75    /// Create a config with an explicit token budget (Enabled mode).
76    #[must_use]
77    pub const fn new(budget_tokens: u32) -> Self {
78        Self {
79            mode: ThinkingMode::Enabled { budget_tokens },
80            effort: None,
81            display: ThinkingDisplay::Omitted,
82        }
83    }
84
85    /// Create an adaptive thinking config.
86    #[must_use]
87    pub const fn adaptive() -> Self {
88        Self {
89            mode: ThinkingMode::Adaptive,
90            effort: None,
91            display: ThinkingDisplay::Omitted,
92        }
93    }
94
95    /// Create an adaptive thinking config with an effort level.
96    #[must_use]
97    pub const fn adaptive_with_effort(effort: Effort) -> Self {
98        Self {
99            mode: ThinkingMode::Adaptive,
100            effort: Some(effort),
101            display: ThinkingDisplay::Omitted,
102        }
103    }
104
105    /// Create a provider-default-mode config with an effort level.
106    #[must_use]
107    pub const fn default_with_effort(effort: Effort) -> Self {
108        Self {
109            mode: ThinkingMode::Default,
110            effort: Some(effort),
111            display: ThinkingDisplay::Omitted,
112        }
113    }
114
115    /// Set how thinking content is returned.
116    #[must_use]
117    pub const fn with_display(mut self, display: ThinkingDisplay) -> Self {
118        self.display = display;
119        self
120    }
121
122    /// Set the effort level on an existing config.
123    #[must_use]
124    pub const fn with_effort(mut self, effort: Effort) -> Self {
125        self.effort = Some(effort);
126        self
127    }
128}
129
130impl Default for ThinkingConfig {
131    fn default() -> Self {
132        Self::new(Self::DEFAULT_BUDGET_TOKENS)
133    }
134}
135
136// ── Request / Response ────────────────────────────────────────────────
137
138/// Controls whether the model must use a tool.
139#[derive(Debug, Clone)]
140pub enum ToolChoice {
141    /// Let the model decide whether to use tools (default when `None`).
142    Auto,
143    /// Force the model to call a specific tool by name.
144    Tool(String),
145}
146
147/// Requests that the model constrain its final answer to a JSON Schema.
148///
149/// This is the wire-level description of a structured-output request. The
150/// runtime maps it to each provider's native capability:
151///
152/// - **`OpenAI` / Gemini**: native JSON-mode / structured-outputs
153///   (`response_format` / `responseSchema`).
154/// - **Anthropic**: tool-forcing fallback — the runtime injects a single
155///   "respond" tool whose `input_schema` is [`schema`](Self::schema) and
156///   forces the model to call it.
157///
158/// The runtime validates the model's final output against [`schema`](Self::schema)
159/// and, on mismatch, bounded-re-prompts before failing with a typed error.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ResponseFormat {
162    /// Stable identifier for the schema. Surfaced to providers that require a
163    /// name (`OpenAI` `json_schema.name`, the Anthropic fallback tool name).
164    pub name: String,
165    /// The JSON Schema the final assistant output must satisfy.
166    ///
167    /// This is a raw JSON Schema document (an object), not a Rust type. Callers
168    /// that derive schemas from Rust types can plug in `schemars` upstream and
169    /// pass the resulting document here.
170    pub schema: serde_json::Value,
171    /// Whether the provider should enforce strict schema adherence when it
172    /// supports a strict mode (`OpenAI` `strict: true`). Has no effect on
173    /// providers without a strict mode.
174    pub strict: bool,
175}
176
177impl ResponseFormat {
178    /// Create a response format from a schema name and a JSON Schema document.
179    ///
180    /// Defaults to `strict = true` so providers with a strict mode enforce the
181    /// schema rather than treating it as a hint.
182    #[must_use]
183    pub fn new(name: impl Into<String>, schema: serde_json::Value) -> Self {
184        Self {
185            name: name.into(),
186            schema,
187            strict: true,
188        }
189    }
190
191    /// Set whether strict schema adherence is requested.
192    #[must_use]
193    pub const fn with_strict(mut self, strict: bool) -> Self {
194        self.strict = strict;
195        self
196    }
197}
198
199/// Time-to-live for a provider-side prompt-cache breakpoint.
200///
201/// Only the values the Anthropic Messages API accepts are modelled, so the
202/// enum maps losslessly onto the wire `ttl` string. Providers without an
203/// equivalent control ignore it.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum CacheTtl {
206    /// Five-minute ephemeral cache (the provider default).
207    FiveMinutes,
208    /// One-hour ephemeral cache (extended retention).
209    OneHour,
210}
211
212impl CacheTtl {
213    /// The wire string a provider sends for this TTL (`"5m"` / `"1h"`).
214    #[must_use]
215    pub const fn as_wire_str(self) -> &'static str {
216        match self {
217            Self::FiveMinutes => "5m",
218            Self::OneHour => "1h",
219        }
220    }
221}
222
223/// Caller-facing control over provider-side prompt caching.
224///
225/// This is additive: a [`ChatRequest`] with `cache = None` preserves each
226/// provider's default caching behaviour. Set it to shape (or disable) caching:
227///
228/// - `enabled = false` opts the request out of caching entirely — providers
229///   send no `cache_control` breakpoints.
230/// - `ttl` selects the cache retention window (Anthropic ephemeral TTL).
231/// - `max_breakpoints` caps how many cache breakpoints the provider may emit,
232///   in decreasing order of prefix stability (tools, then system, then the
233///   conversation tail). `None` leaves the provider's default count.
234///
235/// Providers without a prompt-cache control ignore every field gracefully.
236#[derive(Debug, Clone)]
237pub struct CacheConfig {
238    /// Whether prompt caching is enabled for this request.
239    pub enabled: bool,
240    /// Optional cache retention window. `None` uses the provider default.
241    pub ttl: Option<CacheTtl>,
242    /// Optional cap on the number of cache breakpoints the provider emits.
243    pub max_breakpoints: Option<u8>,
244}
245
246impl Default for CacheConfig {
247    fn default() -> Self {
248        Self::enabled()
249    }
250}
251
252impl CacheConfig {
253    /// An enabled cache config with provider defaults (no TTL override, all
254    /// breakpoints).
255    #[must_use]
256    pub const fn enabled() -> Self {
257        Self {
258            enabled: true,
259            ttl: None,
260            max_breakpoints: None,
261        }
262    }
263
264    /// A config that opts the request out of provider-side caching.
265    #[must_use]
266    pub const fn disabled() -> Self {
267        Self {
268            enabled: false,
269            ttl: None,
270            max_breakpoints: None,
271        }
272    }
273
274    /// Set the cache retention window.
275    #[must_use]
276    pub const fn with_ttl(mut self, ttl: CacheTtl) -> Self {
277        self.ttl = Some(ttl);
278        self
279    }
280
281    /// Cap the number of cache breakpoints the provider may emit.
282    #[must_use]
283    pub const fn with_max_breakpoints(mut self, max_breakpoints: u8) -> Self {
284        self.max_breakpoints = Some(max_breakpoints);
285        self
286    }
287}
288
289#[derive(Debug, Clone)]
290pub struct ChatRequest {
291    pub system: String,
292    pub messages: Vec<Message>,
293    pub tools: Option<Vec<Tool>>,
294    pub max_tokens: u32,
295    /// Whether `max_tokens` was explicitly configured by the caller.
296    pub max_tokens_explicit: bool,
297    /// Optional session identifier for provider-side prompt caching or routing.
298    pub session_id: Option<String>,
299    /// Optional provider-managed cached content reference.
300    ///
301    /// This currently maps to Gemini / Vertex AI `cachedContent` handles.
302    pub cached_content: Option<String>,
303    /// Optional extended thinking configuration.
304    pub thinking: Option<ThinkingConfig>,
305    /// Optional constraint on tool usage.
306    ///
307    /// When `None` the provider's default behaviour applies (typically `auto`).
308    pub tool_choice: Option<ToolChoice>,
309    /// Optional request for the final answer to be constrained to a JSON
310    /// Schema.
311    ///
312    /// When `Some`, the provider maps this to its native JSON-mode /
313    /// structured-output capability (or a tool-forcing fallback) and the
314    /// runtime validates the final output against the schema. When `None`
315    /// (default) the model responds freely.
316    pub response_format: Option<ResponseFormat>,
317    /// Optional control over provider-side prompt caching.
318    ///
319    /// When `None` (default) each provider keeps its built-in caching
320    /// behaviour. When `Some`, providers that support prompt caching honour
321    /// the [`CacheConfig`] (TTL, opt-out, breakpoint cap); others ignore it.
322    pub cache: Option<CacheConfig>,
323}
324
325impl ChatRequest {
326    /// Default token budget used by [`ChatRequest::new`] when the caller does
327    /// not set one explicitly. Providers clamp this to their own ceiling.
328    pub const DEFAULT_MAX_TOKENS: u32 = 4096;
329
330    /// Build a request from a system prompt and a message list, leaving every
331    /// optional knob at its default.
332    ///
333    /// This is the ergonomic counterpart to the (still-public) struct literal:
334    /// the common case only needs `system` + `messages`, so callers no longer
335    /// have to spell out the eight `None`/default fields. Layer optional
336    /// settings on with the chainable `with_*` setters:
337    ///
338    /// ```
339    /// use agent_sdk_foundation::llm::{ChatRequest, Message, ToolChoice};
340    ///
341    /// let req = ChatRequest::new("You are helpful.", vec![Message::user("Hi")])
342    ///     .with_max_tokens(1024)
343    ///     .with_tool_choice(ToolChoice::Auto);
344    /// ```
345    #[must_use]
346    pub fn new(system: impl Into<String>, messages: Vec<Message>) -> Self {
347        Self {
348            system: system.into(),
349            messages,
350            tools: None,
351            max_tokens: Self::DEFAULT_MAX_TOKENS,
352            max_tokens_explicit: false,
353            session_id: None,
354            cached_content: None,
355            thinking: None,
356            tool_choice: None,
357            response_format: None,
358            cache: None,
359        }
360    }
361
362    /// Set the tool list the model may call.
363    #[must_use]
364    pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
365        self.tools = Some(tools);
366        self
367    }
368
369    /// Set the maximum output-token budget (marks it as explicitly configured).
370    #[must_use]
371    pub const fn with_max_tokens(mut self, max_tokens: u32) -> Self {
372        self.max_tokens = max_tokens;
373        self.max_tokens_explicit = true;
374        self
375    }
376
377    /// Set the session identifier (provider-side prompt caching / routing).
378    #[must_use]
379    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
380        self.session_id = Some(session_id.into());
381        self
382    }
383
384    /// Set the extended-thinking configuration.
385    #[must_use]
386    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
387        self.thinking = Some(thinking);
388        self
389    }
390
391    /// Constrain tool usage (defaults to the provider's `auto` when unset).
392    #[must_use]
393    pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
394        self.tool_choice = Some(tool_choice);
395        self
396    }
397
398    /// Request the final answer be constrained to the given JSON-Schema
399    /// [`ResponseFormat`] (structured output).
400    #[must_use]
401    pub fn with_response_format(mut self, response_format: ResponseFormat) -> Self {
402        self.response_format = Some(response_format);
403        self
404    }
405
406    /// Set the provider-side prompt-cache control ([`CacheConfig`]).
407    #[must_use]
408    pub const fn with_cache(mut self, cache: CacheConfig) -> Self {
409        self.cache = Some(cache);
410        self
411    }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct Message {
416    pub role: Role,
417    pub content: Content,
418}
419
420impl Message {
421    #[must_use]
422    pub fn user(text: impl Into<String>) -> Self {
423        Self {
424            role: Role::User,
425            content: Content::Text(text.into()),
426        }
427    }
428
429    #[must_use]
430    pub const fn user_with_content(blocks: Vec<ContentBlock>) -> Self {
431        Self {
432            role: Role::User,
433            content: Content::Blocks(blocks),
434        }
435    }
436
437    #[must_use]
438    pub fn assistant(text: impl Into<String>) -> Self {
439        Self {
440            role: Role::Assistant,
441            content: Content::Text(text.into()),
442        }
443    }
444
445    #[must_use]
446    pub const fn assistant_with_content(blocks: Vec<ContentBlock>) -> Self {
447        Self {
448            role: Role::Assistant,
449            content: Content::Blocks(blocks),
450        }
451    }
452
453    #[must_use]
454    pub fn assistant_with_tool_use(
455        text: Option<String>,
456        id: impl Into<String>,
457        name: impl Into<String>,
458        input: serde_json::Value,
459    ) -> Self {
460        let mut blocks = Vec::new();
461        if let Some(t) = text {
462            blocks.push(ContentBlock::Text { text: t });
463        }
464        blocks.push(ContentBlock::ToolUse {
465            id: id.into(),
466            name: name.into(),
467            input,
468            thought_signature: None,
469        });
470        Self {
471            role: Role::Assistant,
472            content: Content::Blocks(blocks),
473        }
474    }
475
476    #[must_use]
477    pub fn tool_result(
478        tool_use_id: impl Into<String>,
479        content: impl Into<String>,
480        is_error: bool,
481    ) -> Self {
482        Self {
483            role: Role::User,
484            content: Content::Blocks(vec![ContentBlock::ToolResult {
485                tool_use_id: tool_use_id.into(),
486                content: content.into(),
487                is_error: if is_error { Some(true) } else { None },
488            }]),
489        }
490    }
491}
492
493#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
494#[serde(rename_all = "lowercase")]
495pub enum Role {
496    User,
497    Assistant,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
501#[serde(untagged)]
502pub enum Content {
503    Text(String),
504    Blocks(Vec<ContentBlock>),
505}
506
507impl Content {
508    #[must_use]
509    pub fn first_text(&self) -> Option<&str> {
510        match self {
511            Self::Text(s) => Some(s),
512            Self::Blocks(blocks) => blocks.iter().find_map(|b| match b {
513                ContentBlock::Text { text } => Some(text.as_str()),
514                _ => None,
515            }),
516        }
517    }
518}
519
520/// Source data for image and document content blocks.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct ContentSource {
523    pub media_type: String,
524    pub data: String,
525}
526
527impl ContentSource {
528    #[must_use]
529    pub fn new(media_type: impl Into<String>, data: impl Into<String>) -> Self {
530        Self {
531            media_type: media_type.into(),
532            data: data.into(),
533        }
534    }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
538#[serde(tag = "type")]
539#[non_exhaustive]
540pub enum ContentBlock {
541    #[serde(rename = "text")]
542    Text { text: String },
543
544    #[serde(rename = "thinking")]
545    Thinking {
546        thinking: String,
547        /// Opaque signature for round-tripping thinking blocks back to the API.
548        #[serde(skip_serializing_if = "Option::is_none")]
549        signature: Option<String>,
550    },
551
552    #[serde(rename = "redacted_thinking")]
553    RedactedThinking { data: String },
554
555    /// Provider-owned reasoning state that must be replayed exactly on a
556    /// later request, but must never be interpreted or surfaced by the SDK.
557    ///
558    /// `provider` names the wire protocol that owns `data`; providers must
559    /// ignore blocks owned by a different protocol. The JSON payload is kept
560    /// opaque so a provider can evolve its state-item shape without requiring
561    /// another SDK wire-format change.
562    #[serde(rename = "opaque_reasoning")]
563    OpaqueReasoning {
564        provider: String,
565        data: serde_json::Value,
566    },
567
568    #[serde(rename = "tool_use")]
569    ToolUse {
570        id: String,
571        name: String,
572        input: serde_json::Value,
573        /// Gemini thought signature for preserving reasoning context.
574        /// Required for Gemini 3 models when sending function calls back.
575        #[serde(skip_serializing_if = "Option::is_none")]
576        thought_signature: Option<String>,
577    },
578
579    #[serde(rename = "tool_result")]
580    ToolResult {
581        tool_use_id: String,
582        content: String,
583        #[serde(skip_serializing_if = "Option::is_none")]
584        is_error: Option<bool>,
585    },
586
587    #[serde(rename = "image")]
588    Image { source: ContentSource },
589
590    #[serde(rename = "document")]
591    Document { source: ContentSource },
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595pub struct Tool {
596    pub name: String,
597    pub description: String,
598    pub input_schema: serde_json::Value,
599    /// Human-readable display name shown in UI and audit records.
600    pub display_name: String,
601    /// Permission tier for this tool.
602    pub tier: super::types::ToolTier,
603}
604
605#[derive(Debug, Clone)]
606pub struct ChatResponse {
607    pub id: String,
608    pub content: Vec<ContentBlock>,
609    pub model: String,
610    pub stop_reason: Option<StopReason>,
611    pub usage: Usage,
612}
613
614impl ChatResponse {
615    #[must_use]
616    pub fn first_text(&self) -> Option<&str> {
617        self.content.iter().find_map(|b| match b {
618            ContentBlock::Text { text } => Some(text.as_str()),
619            _ => None,
620        })
621    }
622
623    #[must_use]
624    pub fn first_thinking(&self) -> Option<&str> {
625        self.content.iter().find_map(|b| match b {
626            ContentBlock::Thinking { thinking, .. } => Some(thinking.as_str()),
627            _ => None,
628        })
629    }
630
631    pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
632        self.content.iter().filter_map(|b| match b {
633            ContentBlock::ToolUse {
634                id, name, input, ..
635            } => Some((id.as_str(), name.as_str(), input)),
636            _ => None,
637        })
638    }
639
640    #[must_use]
641    pub fn has_tool_use(&self) -> bool {
642        self.content
643            .iter()
644            .any(|b| matches!(b, ContentBlock::ToolUse { .. }))
645    }
646}
647
648#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
649#[serde(rename_all = "snake_case")]
650#[non_exhaustive]
651pub enum StopReason {
652    EndTurn,
653    ToolUse,
654    MaxTokens,
655    StopSequence,
656    Refusal,
657    ModelContextWindowExceeded,
658    /// A stop reason this version of the SDK does not recognize.
659    ///
660    /// Providers may introduce new stop reasons at any time. Rather than
661    /// failing deserialization of an otherwise-valid response (or a
662    /// persisted/replayed audit row), unknown values map here via
663    /// `#[serde(other)]`. Consumers should treat it like
664    /// [`StopReason::EndTurn`] (turn finished, nothing actionable) unless
665    /// they have a more specific fallback.
666    #[serde(other)]
667    Unknown,
668}
669
670impl StopReason {
671    /// Stable discriminant string used for durable rows, metrics, and
672    /// dashboards.  Matches the serde representation.
673    #[must_use]
674    pub const fn as_str(&self) -> &'static str {
675        match self {
676            Self::EndTurn => "end_turn",
677            Self::ToolUse => "tool_use",
678            Self::MaxTokens => "max_tokens",
679            Self::StopSequence => "stop_sequence",
680            Self::Refusal => "refusal",
681            Self::ModelContextWindowExceeded => "model_context_window_exceeded",
682            Self::Unknown => "unknown",
683        }
684    }
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
688pub struct Usage {
689    /// Total input tokens reported by the provider.
690    pub input_tokens: u32,
691    pub output_tokens: u32,
692    /// Portion of `input_tokens` billed at a cached-input rate, when reported.
693    #[serde(default)]
694    pub cached_input_tokens: u32,
695    /// Portion of `input_tokens` spent creating provider-side prompt cache entries.
696    #[serde(default)]
697    pub cache_creation_input_tokens: u32,
698}
699
700#[derive(Debug, Clone)]
701#[non_exhaustive]
702pub enum ChatOutcome {
703    Success(ChatResponse),
704    /// The provider rate-limited the request (HTTP 429).
705    ///
706    /// Carries the retry delay parsed from the response's `Retry-After`
707    /// header when the provider supplied one (see [`parse_retry_after`]), so
708    /// the caller can honour the server's hint instead of guessing a backoff.
709    /// `None` when no usable `Retry-After` was present.
710    RateLimited(Option<Duration>),
711    InvalidRequest(String),
712    ServerError(String),
713}
714
715/// Parse the value of an HTTP `Retry-After` header into a [`Duration`].
716///
717/// Per [RFC 9110 §10.2.3], `Retry-After` is either a non-negative number of
718/// seconds (delta-seconds) or an IMF-fixdate HTTP timestamp
719/// (`Sun, 06 Nov 1994 08:49:37 GMT`). For the date form the delay is the
720/// difference between that instant and now; a timestamp at or before now (or
721/// any value that cannot be parsed) yields `None`.
722///
723/// [RFC 9110 §10.2.3]: https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3
724#[must_use]
725pub fn parse_retry_after(value: &str) -> Option<Duration> {
726    let trimmed = value.trim();
727    if trimmed.is_empty() {
728        return None;
729    }
730
731    // delta-seconds: a bare non-negative integer number of seconds.
732    if let Ok(seconds) = trimmed.parse::<u64>() {
733        return Some(Duration::from_secs(seconds));
734    }
735
736    // IMF-fixdate: compute the remaining delay from now, dropping past dates.
737    let target = parse_imf_fixdate(trimmed)?;
738    let now = time::OffsetDateTime::now_utc();
739    if target <= now {
740        return None;
741    }
742    (target - now).try_into().ok()
743}
744
745/// Parse an IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`) as a UTC instant.
746fn parse_imf_fixdate(value: &str) -> Option<time::OffsetDateTime> {
747    // IMF-fixdate is always UTC ("GMT"); parse the civil datetime and assume
748    // UTC. A custom description avoids depending on the `macros` feature.
749    let format = time::format_description::parse_borrowed::<1>(
750        "[weekday repr:short], [day] [month repr:short] [year] \
751         [hour]:[minute]:[second] GMT",
752    )
753    .ok()?;
754    time::PrimitiveDateTime::parse(value, &format)
755        .ok()
756        .map(time::PrimitiveDateTime::assume_utc)
757}
758
759// ─────────────────────────────────────────────────────────────────────
760// Tool-use / tool-result balancing
761// ─────────────────────────────────────────────────────────────────────
762
763/// Default `tool_result` text used to close a `tool_use` block the user
764/// cancelled (or otherwise abandoned) before it produced a real result.
765///
766/// Surfaced to the model so it understands the call did not run, rather
767/// than silently dropping the loop. Used by [`balance_tool_results`].
768pub const USER_CANCELLED_TOOL_RESULT: &str = "User cancelled";
769
770/// Collect the `tool_use` block ids carried by a single message, in the
771/// order they appear. Empty for any message that carries no `tool_use`
772/// blocks (the common case for user messages and text-only assistant
773/// turns).
774fn message_tool_use_ids(message: &Message) -> Vec<&str> {
775    match &message.content {
776        Content::Text(_) => Vec::new(),
777        Content::Blocks(blocks) => blocks
778            .iter()
779            .filter_map(|block| match block {
780                ContentBlock::ToolUse { id, .. } => Some(id.as_str()),
781                _ => None,
782            })
783            .collect(),
784    }
785}
786
787/// Collect the set of `tool_use_id`s answered by `tool_result` blocks in a
788/// single message. Empty unless the message actually carries
789/// `tool_result` blocks.
790fn message_tool_result_ids(message: &Message) -> std::collections::HashSet<&str> {
791    match &message.content {
792        Content::Text(_) => std::collections::HashSet::new(),
793        Content::Blocks(blocks) => blocks
794            .iter()
795            .filter_map(|block| match block {
796                ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()),
797                _ => None,
798            })
799            .collect(),
800    }
801}
802
803/// Collect every `tool_use_id` answered by a `tool_result` block *anywhere*
804/// in `messages`.
805///
806/// Answeredness is judged across the whole conversation, not just the
807/// message immediately after a `tool_use`: an id that already has a real
808/// `tool_result` somewhere must never be synthesized again, or balancing
809/// would emit a duplicate `tool_result` for the same id (itself an API
810/// rejection) and mislabel a successful call as cancelled.
811fn all_answered_tool_use_ids(messages: &[Message]) -> std::collections::HashSet<&str> {
812    messages.iter().flat_map(message_tool_result_ids).collect()
813}
814
815/// True when `messages` contains a `tool_use` block whose id is not
816/// answered by any `tool_result` block anywhere in the conversation.
817///
818/// This is exactly the condition the Anthropic Messages API rejects with
819/// *"`tool_use` ids were found without `tool_result` blocks immediately
820/// after"*. It arises whenever a turn is interrupted after the assistant
821/// `tool_use` was persisted but before every result was recorded — most
822/// commonly when the user answers one of several questions and cancels
823/// the rest, or cancels a tool mid-flight.
824#[must_use]
825pub fn has_unbalanced_tool_use(messages: &[Message]) -> bool {
826    let answered = all_answered_tool_use_ids(messages);
827    messages
828        .iter()
829        .flat_map(message_tool_use_ids)
830        .any(|id| !answered.contains(id))
831}
832
833/// Close every unanswered `tool_use` loop in `messages`.
834///
835/// Re-balances the conversation so each `tool_use` block is answered by a
836/// `tool_result` block in the immediately following message, synthesizing
837/// an error `tool_result` carrying `cancel_text` for every id left
838/// unanswered.
839///
840/// The Anthropic Messages API requires that an assistant message's
841/// `tool_use` ids each have a matching `tool_result` in the *next*
842/// message. A turn that is cancelled or abandoned after the assistant
843/// `tool_use` was persisted — but before all tool results landed — leaves
844/// the conversation unbalanced, and the next request 400s. This pass
845/// closes those loops so the conversation can continue.
846///
847/// Behaviour per assistant `tool_use` message:
848/// - An id that already has a real `tool_result` anywhere in the
849///   conversation is left alone (never duplicated or relabelled cancelled).
850/// - If the following message already answers some ids (the partial case:
851///   the user answered one question and cancelled the others), the missing
852///   results are appended to that existing message.
853/// - Otherwise a fresh user message carrying the synthetic results is
854///   inserted directly after the assistant message.
855///
856/// Idempotent and order-preserving: a no-op clone when history is already
857/// balanced (see [`has_unbalanced_tool_use`]).
858#[must_use]
859pub fn balance_tool_results(messages: &[Message], cancel_text: &str) -> Vec<Message> {
860    // Judge answeredness across the whole conversation so a real result
861    // that is not at idx+1 still suppresses synthesis (no duplicate id).
862    let answered = all_answered_tool_use_ids(messages);
863    let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 1);
864    let mut idx = 0;
865    while idx < messages.len() {
866        let message = &messages[idx];
867        let tool_use_ids = message_tool_use_ids(message);
868        if tool_use_ids.is_empty() {
869            out.push(message.clone());
870            idx += 1;
871            continue;
872        }
873
874        let synthetic: Vec<ContentBlock> = tool_use_ids
875            .iter()
876            .filter(|id| !answered.contains(*id))
877            .map(|id| ContentBlock::ToolResult {
878                tool_use_id: (*id).to_owned(),
879                content: cancel_text.to_owned(),
880                is_error: Some(true),
881            })
882            .collect();
883
884        out.push(message.clone());
885
886        let next = messages.get(idx + 1);
887
888        if synthetic.is_empty() {
889            // Already balanced — leave the following message for the next
890            // loop iteration to handle normally.
891            idx += 1;
892            continue;
893        }
894
895        // A following message that already carries tool_result blocks is
896        // *the* results message for this turn (the partial-answer case):
897        // merge the synthetic results into it. Anything else (a fresh user
898        // prompt, another assistant turn, or end-of-history) gets a brand
899        // new results message inserted right after the assistant turn.
900        match next {
901            Some(next_message) if !message_tool_result_ids(next_message).is_empty() => {
902                let mut merged = next_message.clone();
903                if let Content::Blocks(blocks) = &mut merged.content {
904                    blocks.extend(synthetic);
905                } else {
906                    // A text-only message can't carry tool_result blocks, so
907                    // this arm is unreachable given the guard above, but stay
908                    // defensive rather than silently dropping the results.
909                    merged.content = Content::Blocks(synthetic);
910                }
911                out.push(merged);
912                idx += 2;
913            }
914            _ => {
915                out.push(Message::user_with_content(synthetic));
916                idx += 1;
917            }
918        }
919    }
920    out
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn chat_request_new_defaults_then_setters() {
929        let req = ChatRequest::new("sys", vec![Message::user("hi")]);
930        assert_eq!(req.system, "sys");
931        assert_eq!(req.messages.len(), 1);
932        assert_eq!(req.max_tokens, ChatRequest::DEFAULT_MAX_TOKENS);
933        assert!(!req.max_tokens_explicit);
934        assert!(req.tools.is_none());
935        assert!(req.tool_choice.is_none());
936        assert!(req.response_format.is_none());
937
938        let req = req
939            .with_max_tokens(1234)
940            .with_tool_choice(ToolChoice::Auto)
941            .with_response_format(ResponseFormat::new(
942                "r",
943                serde_json::json!({"type": "object"}),
944            ))
945            .with_session_id("s-1");
946        assert_eq!(req.max_tokens, 1234);
947        assert!(req.max_tokens_explicit);
948        assert!(matches!(req.tool_choice, Some(ToolChoice::Auto)));
949        assert!(req.response_format.is_some());
950        assert_eq!(req.session_id.as_deref(), Some("s-1"));
951    }
952
953    #[test]
954    fn stop_reason_known_values_round_trip() -> Result<(), serde_json::Error> {
955        for (json, expected) in [
956            ("\"end_turn\"", StopReason::EndTurn),
957            ("\"tool_use\"", StopReason::ToolUse),
958            ("\"max_tokens\"", StopReason::MaxTokens),
959            ("\"stop_sequence\"", StopReason::StopSequence),
960            ("\"refusal\"", StopReason::Refusal),
961            (
962                "\"model_context_window_exceeded\"",
963                StopReason::ModelContextWindowExceeded,
964            ),
965        ] {
966            let parsed: StopReason = serde_json::from_str(json)?;
967            assert_eq!(parsed, expected);
968            assert_eq!(serde_json::to_string(&parsed)?, json);
969        }
970        Ok(())
971    }
972
973    #[test]
974    fn stop_reason_unknown_value_deserializes_to_unknown() -> Result<(), serde_json::Error> {
975        // An unrecognized provider stop reason must not fail deserialization;
976        // `#[serde(other)]` routes it to `StopReason::Unknown`.
977        let parsed: StopReason = serde_json::from_str("\"some_future_reason\"")?;
978        assert_eq!(parsed, StopReason::Unknown);
979        assert_eq!(parsed.as_str(), "unknown");
980        Ok(())
981    }
982
983    #[test]
984    fn stop_reason_unknown_serializes_to_unknown() -> Result<(), serde_json::Error> {
985        assert_eq!(serde_json::to_string(&StopReason::Unknown)?, "\"unknown\"");
986        Ok(())
987    }
988
989    // ── ContentBlock wire format ────────────────────────────────
990    //
991    // `ContentBlock` is persisted durably (AgentContinuation.response_content,
992    // AgentEvent::UserInput), so its tag strings and optional-field omission
993    // are part of the wire contract. A tag rename or variant reorder must fail
994    // a test here, not silently corrupt persisted threads.
995
996    #[test]
997    fn content_block_text_wire_format() -> Result<(), serde_json::Error> {
998        let json = serde_json::to_value(ContentBlock::Text { text: "hi".into() })?;
999        assert_eq!(json, serde_json::json!({"type": "text", "text": "hi"}));
1000        Ok(())
1001    }
1002
1003    #[test]
1004    fn content_block_thinking_omits_none_signature() -> Result<(), serde_json::Error> {
1005        let none = serde_json::to_value(ContentBlock::Thinking {
1006            thinking: "t".into(),
1007            signature: None,
1008        })?;
1009        assert_eq!(
1010            none,
1011            serde_json::json!({"type": "thinking", "thinking": "t"})
1012        );
1013
1014        let some = serde_json::to_value(ContentBlock::Thinking {
1015            thinking: "t".into(),
1016            signature: Some("sig".into()),
1017        })?;
1018        assert_eq!(
1019            some,
1020            serde_json::json!({"type": "thinking", "thinking": "t", "signature": "sig"})
1021        );
1022        Ok(())
1023    }
1024
1025    #[test]
1026    fn content_block_tool_use_omits_none_thought_signature() -> Result<(), serde_json::Error> {
1027        let none = serde_json::to_value(ContentBlock::ToolUse {
1028            id: "i".into(),
1029            name: "n".into(),
1030            input: serde_json::json!({"a": 1}),
1031            thought_signature: None,
1032        })?;
1033        assert_eq!(
1034            none,
1035            serde_json::json!({"type": "tool_use", "id": "i", "name": "n", "input": {"a": 1}})
1036        );
1037
1038        let some = serde_json::to_value(ContentBlock::ToolUse {
1039            id: "i".into(),
1040            name: "n".into(),
1041            input: serde_json::json!({}),
1042            thought_signature: Some("ts".into()),
1043        })?;
1044        assert_eq!(
1045            some.get("thought_signature").and_then(|v| v.as_str()),
1046            Some("ts")
1047        );
1048        Ok(())
1049    }
1050
1051    #[test]
1052    fn content_block_tool_result_omits_none_is_error() -> Result<(), serde_json::Error> {
1053        let none = serde_json::to_value(ContentBlock::ToolResult {
1054            tool_use_id: "t".into(),
1055            content: "out".into(),
1056            is_error: None,
1057        })?;
1058        assert_eq!(
1059            none,
1060            serde_json::json!({"type": "tool_result", "tool_use_id": "t", "content": "out"})
1061        );
1062
1063        let some = serde_json::to_value(ContentBlock::ToolResult {
1064            tool_use_id: "t".into(),
1065            content: "out".into(),
1066            is_error: Some(true),
1067        })?;
1068        assert_eq!(
1069            some.get("is_error").and_then(serde_json::Value::as_bool),
1070            Some(true)
1071        );
1072        Ok(())
1073    }
1074
1075    #[test]
1076    fn content_block_remaining_variant_tags() -> Result<(), serde_json::Error> {
1077        assert_eq!(
1078            serde_json::to_value(ContentBlock::RedactedThinking { data: "d".into() })?,
1079            serde_json::json!({"type": "redacted_thinking", "data": "d"})
1080        );
1081        assert_eq!(
1082            serde_json::to_value(ContentBlock::Image {
1083                source: ContentSource::new("image/png", "b64"),
1084            })?,
1085            serde_json::json!({"type": "image", "source": {"media_type": "image/png", "data": "b64"}})
1086        );
1087        assert_eq!(
1088            serde_json::to_value(ContentBlock::Document {
1089                source: ContentSource::new("application/pdf", "b64"),
1090            })?,
1091            serde_json::json!({"type": "document", "source": {"media_type": "application/pdf", "data": "b64"}})
1092        );
1093        assert_eq!(
1094            serde_json::to_value(ContentBlock::OpaqueReasoning {
1095                provider: "test-provider".into(),
1096                data: serde_json::json!({"id": "reasoning_1", "encrypted": "ciphertext"}),
1097            })?,
1098            serde_json::json!({
1099                "type": "opaque_reasoning",
1100                "provider": "test-provider",
1101                "data": {"id": "reasoning_1", "encrypted": "ciphertext"}
1102            })
1103        );
1104        Ok(())
1105    }
1106
1107    #[test]
1108    fn content_block_every_tag_round_trips() -> Result<(), serde_json::Error> {
1109        let blocks = vec![
1110            ContentBlock::Text { text: "t".into() },
1111            ContentBlock::Thinking {
1112                thinking: "th".into(),
1113                signature: Some("s".into()),
1114            },
1115            ContentBlock::RedactedThinking { data: "d".into() },
1116            ContentBlock::OpaqueReasoning {
1117                provider: "test-provider".into(),
1118                data: serde_json::json!({"id": "reasoning_1", "state": [1, 2, 3]}),
1119            },
1120            ContentBlock::ToolUse {
1121                id: "i".into(),
1122                name: "n".into(),
1123                input: serde_json::json!({"x": 1}),
1124                thought_signature: None,
1125            },
1126            ContentBlock::ToolResult {
1127                tool_use_id: "t".into(),
1128                content: "c".into(),
1129                is_error: Some(true),
1130            },
1131            ContentBlock::Image {
1132                source: ContentSource::new("image/png", "b"),
1133            },
1134            ContentBlock::Document {
1135                source: ContentSource::new("application/pdf", "b"),
1136            },
1137        ];
1138        for block in blocks {
1139            let json = serde_json::to_value(&block)?;
1140            let back: ContentBlock = serde_json::from_value(json.clone())?;
1141            assert_eq!(serde_json::to_value(&back)?, json);
1142        }
1143        Ok(())
1144    }
1145
1146    // ── Content (untagged) wire format ──────────────────────────
1147
1148    #[test]
1149    fn content_text_serializes_as_bare_string() -> Result<(), serde_json::Error> {
1150        let json = serde_json::to_value(Content::Text("hello".into()))?;
1151        assert_eq!(json, serde_json::json!("hello"));
1152        let back: Content = serde_json::from_value(serde_json::json!("hello"))?;
1153        assert!(matches!(back, Content::Text(s) if s == "hello"));
1154        Ok(())
1155    }
1156
1157    #[test]
1158    fn content_blocks_serialize_as_array_including_empty() -> Result<(), serde_json::Error> {
1159        let json = serde_json::to_value(Content::Blocks(vec![ContentBlock::Text {
1160            text: "x".into(),
1161        }]))?;
1162        assert_eq!(json, serde_json::json!([{"type": "text", "text": "x"}]));
1163
1164        // Empty blocks → `[]` and must round-trip back to `Blocks`, not `Text`,
1165        // even though `Text` is the first untagged variant.
1166        let empty = serde_json::to_value(Content::Blocks(vec![]))?;
1167        assert_eq!(empty, serde_json::json!([]));
1168        let back: Content = serde_json::from_value(empty)?;
1169        assert!(matches!(back, Content::Blocks(b) if b.is_empty()));
1170        Ok(())
1171    }
1172
1173    // ── Message wire format ─────────────────────────────────────
1174
1175    #[test]
1176    fn message_wire_format_text_and_blocks() -> Result<(), serde_json::Error> {
1177        let user = serde_json::to_value(Message::user("hi"))?;
1178        assert_eq!(user, serde_json::json!({"role": "user", "content": "hi"}));
1179
1180        let assistant =
1181            serde_json::to_value(Message::assistant_with_content(vec![ContentBlock::Text {
1182                text: "yo".into(),
1183            }]))?;
1184        assert_eq!(
1185            assistant,
1186            serde_json::json!({"role": "assistant", "content": [{"type": "text", "text": "yo"}]})
1187        );
1188
1189        let back: Message =
1190            serde_json::from_value(serde_json::json!({"role": "user", "content": "hi"}))?;
1191        assert_eq!(back.role, Role::User);
1192        assert!(matches!(back.content, Content::Text(s) if s == "hi"));
1193        Ok(())
1194    }
1195
1196    // ── Retry-After parsing ─────────────────────────────────────
1197
1198    #[test]
1199    fn parse_retry_after_delta_seconds() {
1200        assert_eq!(parse_retry_after("125"), Some(Duration::from_secs(125)));
1201        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1202        // Surrounding whitespace is tolerated.
1203        assert_eq!(parse_retry_after("  30 "), Some(Duration::from_secs(30)));
1204    }
1205
1206    #[test]
1207    fn parse_retry_after_rejects_garbage_and_empty() {
1208        assert_eq!(parse_retry_after(""), None);
1209        assert_eq!(parse_retry_after("   "), None);
1210        assert_eq!(parse_retry_after("soon"), None);
1211        // Negative deltas are not valid delta-seconds.
1212        assert_eq!(parse_retry_after("-5"), None);
1213    }
1214
1215    #[test]
1216    fn parse_retry_after_past_imf_date_is_none() {
1217        // A date well in the past must not produce a (would-be negative) delay.
1218        assert_eq!(parse_retry_after("Sun, 06 Nov 1994 08:49:37 GMT"), None);
1219    }
1220
1221    #[test]
1222    fn parse_retry_after_future_imf_date_is_some() {
1223        // Far-future date: must parse and yield a positive, large delay (the
1224        // 1_000_000s ≈ 11.6-day lower bound is trivially exceeded by a year-9999
1225        // target and avoids a round-unit literal).
1226        let parsed = parse_retry_after("Fri, 31 Dec 9999 23:59:59 GMT");
1227        assert!(parsed.is_some_and(|d| d > Duration::from_secs(1_000_000)));
1228    }
1229
1230    // ── CacheConfig ─────────────────────────────────────────────
1231
1232    #[test]
1233    fn cache_ttl_wire_strings() {
1234        assert_eq!(CacheTtl::FiveMinutes.as_wire_str(), "5m");
1235        assert_eq!(CacheTtl::OneHour.as_wire_str(), "1h");
1236    }
1237
1238    #[test]
1239    fn cache_config_builders_and_default_request_cache_is_none() {
1240        let req = ChatRequest::new("sys", vec![Message::user("hi")]);
1241        assert!(
1242            req.cache.is_none(),
1243            "default request must not set a cache config"
1244        );
1245
1246        let enabled = CacheConfig::enabled().with_ttl(CacheTtl::OneHour);
1247        assert!(enabled.enabled);
1248        assert_eq!(enabled.ttl, Some(CacheTtl::OneHour));
1249        assert_eq!(enabled.max_breakpoints, None);
1250
1251        let disabled = CacheConfig::disabled();
1252        assert!(!disabled.enabled);
1253
1254        let capped = CacheConfig::enabled().with_max_breakpoints(2);
1255        assert_eq!(capped.max_breakpoints, Some(2));
1256
1257        let req = ChatRequest::new("s", vec![]).with_cache(CacheConfig::disabled());
1258        assert!(req.cache.is_some_and(|c| !c.enabled));
1259    }
1260
1261    fn assistant_tool_uses(ids: &[&str]) -> Message {
1262        let blocks = ids
1263            .iter()
1264            .map(|id| ContentBlock::ToolUse {
1265                id: (*id).to_string(),
1266                name: "ask_user".to_string(),
1267                input: serde_json::json!({}),
1268                thought_signature: None,
1269            })
1270            .collect();
1271        Message::assistant_with_content(blocks)
1272    }
1273
1274    fn tool_results(ids: &[&str]) -> Message {
1275        let blocks = ids
1276            .iter()
1277            .map(|id| ContentBlock::ToolResult {
1278                tool_use_id: (*id).to_string(),
1279                content: "answered".to_string(),
1280                is_error: None,
1281            })
1282            .collect();
1283        Message::user_with_content(blocks)
1284    }
1285
1286    fn assert_balanced(messages: &[Message]) {
1287        assert!(
1288            !has_unbalanced_tool_use(messages),
1289            "expected balanced history, found an orphaned tool_use",
1290        );
1291    }
1292
1293    #[test]
1294    fn balanced_history_is_left_untouched() {
1295        let messages = vec![
1296            Message::user("hi"),
1297            assistant_tool_uses(&["a"]),
1298            tool_results(&["a"]),
1299        ];
1300        assert!(!has_unbalanced_tool_use(&messages));
1301        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1302        assert_eq!(out.len(), 3);
1303        assert_balanced(&out);
1304    }
1305
1306    #[test]
1307    fn partial_cancellation_merges_into_existing_results_message() {
1308        // Four questions, one answered, three cancelled.
1309        let messages = vec![
1310            assistant_tool_uses(&["q1", "q2", "q3", "q4"]),
1311            tool_results(&["q1"]),
1312        ];
1313        assert!(has_unbalanced_tool_use(&messages));
1314
1315        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1316        assert_eq!(
1317            out.len(),
1318            2,
1319            "synthetic results merge into the existing message"
1320        );
1321        assert_balanced(&out);
1322
1323        let Content::Blocks(blocks) = &out[1].content else {
1324            panic!("results message must carry blocks");
1325        };
1326        let cancelled: Vec<&str> = blocks
1327            .iter()
1328            .filter_map(|b| match b {
1329                ContentBlock::ToolResult {
1330                    tool_use_id,
1331                    content,
1332                    is_error: Some(true),
1333                } if content == USER_CANCELLED_TOOL_RESULT => Some(tool_use_id.as_str()),
1334                _ => None,
1335            })
1336            .collect();
1337        assert_eq!(cancelled, vec!["q2", "q3", "q4"]);
1338    }
1339
1340    #[test]
1341    fn all_cancelled_with_no_following_message_appends_results() {
1342        // Cancel-all: the assistant turn is the last message, no results at all.
1343        let messages = vec![assistant_tool_uses(&["q1", "q2"])];
1344        assert!(has_unbalanced_tool_use(&messages));
1345
1346        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1347        assert_eq!(out.len(), 2, "a fresh results message is inserted");
1348        assert_eq!(out[1].role, Role::User);
1349        assert_balanced(&out);
1350    }
1351
1352    #[test]
1353    fn orphan_followed_by_user_prompt_inserts_results_between() {
1354        // A fresh user turn arrived after an abandoned tool_use turn: the
1355        // results must be inserted *between* them, not after the prompt.
1356        let messages = vec![
1357            assistant_tool_uses(&["q1"]),
1358            Message::user("a brand new question from the user"),
1359        ];
1360        assert!(has_unbalanced_tool_use(&messages));
1361
1362        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1363        assert_eq!(out.len(), 3);
1364        assert_balanced(&out);
1365        // Order: assistant tool_use, synthetic results, then the user prompt.
1366        assert!(!message_tool_use_ids(&out[0]).is_empty());
1367        assert!(!message_tool_result_ids(&out[1]).is_empty());
1368        assert_eq!(
1369            out[2].content.first_text(),
1370            Some("a brand new question from the user")
1371        );
1372    }
1373
1374    #[test]
1375    fn balancing_is_idempotent() {
1376        let messages = vec![
1377            assistant_tool_uses(&["q1", "q2", "q3"]),
1378            tool_results(&["q2"]),
1379        ];
1380        let once = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1381        let twice = balance_tool_results(&once, USER_CANCELLED_TOOL_RESULT);
1382        assert_eq!(once.len(), twice.len());
1383        assert_balanced(&twice);
1384    }
1385
1386    #[test]
1387    fn no_tool_use_history_is_a_noop() {
1388        let messages = vec![Message::user("hi"), Message::assistant("hello")];
1389        assert!(!has_unbalanced_tool_use(&messages));
1390        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1391        assert_eq!(out.len(), 2);
1392    }
1393
1394    #[test]
1395    fn real_result_not_at_idx1_is_not_duplicated_or_relabelled() {
1396        // A `tool_use` whose genuine result is separated from it by another
1397        // message must NOT get a synthetic "User cancelled" result — that
1398        // would emit two tool_result blocks for the same id (a 400) and lie
1399        // that a successful call was cancelled. Answeredness is judged over
1400        // the whole conversation, so the real result suppresses synthesis.
1401        let messages = vec![
1402            assistant_tool_uses(&["a"]),
1403            Message::user("an interjection between the call and its result"),
1404            tool_results(&["a"]),
1405        ];
1406        // No id is genuinely unanswered, so there is nothing to balance.
1407        assert!(!has_unbalanced_tool_use(&messages));
1408
1409        let out = balance_tool_results(&messages, USER_CANCELLED_TOOL_RESULT);
1410        // Exactly one tool_result for "a", and none of them is a synthetic
1411        // cancellation.
1412        let a_results: Vec<&ContentBlock> = out
1413            .iter()
1414            .flat_map(|m| match &m.content {
1415                Content::Blocks(b) => b.as_slice(),
1416                Content::Text(_) => &[][..],
1417            })
1418            .filter(
1419                |b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "a"),
1420            )
1421            .collect();
1422        assert_eq!(a_results.len(), 1, "must not duplicate the real result");
1423        assert!(
1424            !matches!(a_results[0], ContentBlock::ToolResult { content, .. } if content == USER_CANCELLED_TOOL_RESULT),
1425            "the real successful result must not be relabelled cancelled",
1426        );
1427    }
1428}