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