Skip to main content

mentra_provider/
response.rs

1use serde::Deserialize;
2use serde::Serialize;
3
4use crate::error::ProviderError;
5use crate::model::ContentBlock;
6use crate::model::HostedToolSearchCall;
7use crate::model::HostedWebSearchCall;
8use crate::model::ImageGenerationCall;
9use crate::model::ReasoningProvenance;
10use crate::model::Role;
11use crate::model::TokenUsage;
12use crate::model::ToolResultContent;
13use crate::request::CompactionInputItem;
14use crate::stream::ContentBlockDelta;
15use crate::stream::ContentBlockStart;
16use crate::stream::ProviderEvent;
17use crate::stream::ProviderEventStream;
18
19/// A complete response collected from a provider stream.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct Response {
22    pub id: String,
23    pub model: String,
24    pub role: Role,
25    pub content: Vec<ContentBlock>,
26    pub stop_reason: Option<String>,
27    pub usage: Option<TokenUsage>,
28}
29
30/// A complete history-compaction response collected from a provider.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct CompactionResponse {
33    pub output: Vec<CompactionInputItem>,
34    /// Token usage reported for the provider request that produced this response.
35    ///
36    /// `None` means the provider reported no usage; it must not be interpreted as
37    /// a zero-token request.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub usage: Option<TokenUsage>,
40}
41
42/// A complete memory summarize response collected from a provider.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct MemorySummarizeResponse {
45    pub output: Vec<MemorySummarizeOutput>,
46}
47
48/// One summary object for a single raw memory input.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct MemorySummarizeOutput {
51    #[serde(rename = "trace_summary", alias = "raw_memory")]
52    pub raw_memory: String,
53    pub memory_summary: String,
54}
55
56/// Rebuilds a full response from a provider event stream.
57pub async fn collect_response_from_stream(
58    mut stream: ProviderEventStream,
59) -> Result<Response, ProviderError> {
60    let mut builder = StreamingResponseBuilder::default();
61
62    while let Some(event) = stream.recv().await {
63        builder.apply(event?)?;
64    }
65
66    builder.build()
67}
68
69/// Converts a response into a provider event stream.
70pub fn provider_event_stream_from_response(response: Response) -> ProviderEventStream {
71    let events = response.into_provider_events();
72    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
73
74    for event in events {
75        if tx.send(Ok(event)).is_err() {
76            break;
77        }
78    }
79
80    rx
81}
82
83impl Response {
84    pub fn into_provider_events(self) -> Vec<ProviderEvent> {
85        let mut events = vec![ProviderEvent::MessageStarted {
86            id: self.id,
87            model: self.model,
88            role: self.role,
89        }];
90
91        for (index, block) in self.content.into_iter().enumerate() {
92            events.extend(block.into_provider_events(index));
93        }
94
95        events.push(ProviderEvent::MessageDelta {
96            stop_reason: self.stop_reason,
97            usage: self.usage,
98        });
99        events.push(ProviderEvent::MessageStopped);
100        events
101    }
102
103    /// Converts a normal response into a compaction response by collecting its text output.
104    pub fn into_compaction_response(self) -> CompactionResponse {
105        let Response { content, usage, .. } = self;
106        let text = content
107            .into_iter()
108            .filter_map(|block| match block {
109                ContentBlock::Text { text } => Some(text),
110                _ => None,
111            })
112            .collect::<Vec<_>>()
113            .join("\n")
114            .trim()
115            .to_string();
116
117        CompactionResponse {
118            output: vec![CompactionInputItem::CompactionSummary { content: text }],
119            usage,
120        }
121    }
122
123    /// Converts a normal response into a memory summarize response by parsing its text output.
124    pub fn into_memory_summarize_response(self) -> Result<MemorySummarizeResponse, ProviderError> {
125        let text = self
126            .content
127            .into_iter()
128            .filter_map(|block| match block {
129                ContentBlock::Text { text } => Some(text),
130                _ => None,
131            })
132            .collect::<Vec<_>>()
133            .join("\n")
134            .trim()
135            .to_string();
136
137        MemorySummarizeResponse::from_text(&text)
138    }
139}
140
141impl CompactionResponse {
142    /// Wraps a text summary into a single compaction-summary item.
143    pub fn from_text(text: impl Into<String>) -> Self {
144        Self {
145            output: vec![CompactionInputItem::CompactionSummary {
146                content: text.into(),
147            }],
148            usage: None,
149        }
150    }
151}
152
153impl MemorySummarizeResponse {
154    pub fn from_text(text: &str) -> Result<Self, ProviderError> {
155        let text = strip_markdown_code_fence(text);
156
157        if let Ok(output) = serde_json::from_str::<Vec<MemorySummarizeOutput>>(text) {
158            return Ok(Self { output });
159        }
160
161        if let Ok(response) = serde_json::from_str::<MemorySummarizeResponse>(text) {
162            return Ok(response);
163        }
164
165        Err(ProviderError::InvalidResponse(
166            "failed to parse memory summarize response json".to_string(),
167        ))
168    }
169}
170
171fn strip_markdown_code_fence(text: &str) -> &str {
172    let trimmed = text.trim();
173    let Some(rest) = trimmed.strip_prefix("```") else {
174        return trimmed;
175    };
176    let rest = rest
177        .strip_prefix("json")
178        .or_else(|| rest.strip_prefix("JSON"))
179        .unwrap_or(rest);
180    rest.trim()
181        .strip_suffix("```")
182        .map(str::trim)
183        .unwrap_or(trimmed)
184}
185
186#[derive(Default)]
187struct StreamingResponseBuilder {
188    id: Option<String>,
189    model: Option<String>,
190    role: Option<Role>,
191    blocks: std::collections::BTreeMap<usize, StreamingContentBlock>,
192    stop_reason: Option<String>,
193    usage: Option<TokenUsage>,
194    stopped: bool,
195}
196
197impl StreamingResponseBuilder {
198    fn apply(&mut self, event: ProviderEvent) -> Result<(), ProviderError> {
199        match event {
200            ProviderEvent::ResponseHeaders(_) | ProviderEvent::ResponseCreated => {}
201            ProviderEvent::MessageStarted { id, model, role } => {
202                self.id = Some(id);
203                self.model = Some(model);
204                self.role = Some(role);
205            }
206            ProviderEvent::ContentBlockStarted { index, kind } => {
207                self.blocks.insert(index, StreamingContentBlock::from(kind));
208            }
209            ProviderEvent::ContentBlockDelta { index, delta } => {
210                let block = self.blocks.get_mut(&index).ok_or_else(|| {
211                    ProviderError::MalformedStream(format!(
212                        "content block delta received before start for index {index}"
213                    ))
214                })?;
215                block.apply_delta(delta)?;
216            }
217            ProviderEvent::ContentBlockStopped { index } => {
218                let block = self.blocks.get_mut(&index).ok_or_else(|| {
219                    ProviderError::MalformedStream(format!(
220                        "content block stop received before start for index {index}"
221                    ))
222                })?;
223                block.mark_complete();
224            }
225            ProviderEvent::MessageDelta { stop_reason, usage } => {
226                self.stop_reason = stop_reason;
227                self.usage = usage;
228            }
229            ProviderEvent::ReasoningSummaryDelta { .. }
230            | ProviderEvent::ReasoningContentDelta { .. }
231            | ProviderEvent::ReasoningSummaryPartAdded { .. } => {}
232            ProviderEvent::MessageStopped => {
233                self.stopped = true;
234            }
235        }
236
237        Ok(())
238    }
239
240    fn build(self) -> Result<Response, ProviderError> {
241        if !self.stopped {
242            return Err(ProviderError::MalformedStream(
243                "message stream ended before MessageStopped".to_string(),
244            ));
245        }
246
247        let id = self
248            .id
249            .ok_or_else(|| ProviderError::MalformedStream("missing message id".to_string()))?;
250        let model = self
251            .model
252            .ok_or_else(|| ProviderError::MalformedStream("missing model id".to_string()))?;
253        let role = self
254            .role
255            .ok_or_else(|| ProviderError::MalformedStream("missing message role".to_string()))?;
256        let mut content = Vec::with_capacity(self.blocks.len());
257
258        for (index, block) in self.blocks {
259            if !block.is_complete() {
260                return Err(ProviderError::MalformedStream(format!(
261                    "content block {index} did not complete"
262                )));
263            }
264            content.push(block.try_into_content_block()?);
265        }
266
267        Ok(Response {
268            id,
269            model,
270            role,
271            content,
272            stop_reason: self.stop_reason,
273            usage: self.usage,
274        })
275    }
276}
277
278enum StreamingContentBlock {
279    Text {
280        text: String,
281        complete: bool,
282    },
283    Thinking {
284        thinking: String,
285        signature: Option<String>,
286        encrypted_content: Option<String>,
287        id: Option<String>,
288        provenance: Option<ReasoningProvenance>,
289        redacted: bool,
290        complete: bool,
291    },
292    Image {
293        source: crate::model::ImageSource,
294        complete: bool,
295    },
296    ToolUse {
297        id: String,
298        name: String,
299        input_json: String,
300        complete: bool,
301    },
302    ToolResult {
303        tool_use_id: String,
304        content: Option<ToolResultContent>,
305        is_error: bool,
306        complete: bool,
307    },
308    HostedToolSearch {
309        call: HostedToolSearchCall,
310        complete: bool,
311    },
312    HostedWebSearch {
313        call: HostedWebSearchCall,
314        complete: bool,
315    },
316    ImageGeneration {
317        call: ImageGenerationCall,
318        complete: bool,
319    },
320}
321
322impl StreamingContentBlock {
323    fn apply_delta(&mut self, delta: ContentBlockDelta) -> Result<(), ProviderError> {
324        match (self, delta) {
325            (StreamingContentBlock::Text { text, .. }, ContentBlockDelta::Text(delta)) => {
326                text.push_str(&delta);
327                Ok(())
328            }
329            (
330                StreamingContentBlock::Thinking { thinking, .. },
331                ContentBlockDelta::ThinkingText(delta),
332            ) => {
333                thinking.push_str(&delta);
334                Ok(())
335            }
336            (
337                StreamingContentBlock::Thinking { signature, .. },
338                ContentBlockDelta::ThinkingSignature(delta),
339            ) => {
340                signature.get_or_insert_with(String::new).push_str(&delta);
341                Ok(())
342            }
343            (
344                StreamingContentBlock::Thinking {
345                    encrypted_content, ..
346                },
347                ContentBlockDelta::ThinkingEncryptedContent(value),
348            ) => {
349                *encrypted_content = Some(value);
350                Ok(())
351            }
352            (
353                StreamingContentBlock::ToolUse { input_json, .. },
354                ContentBlockDelta::ToolUseInputJson(delta),
355            ) => {
356                input_json.push_str(&delta);
357                Ok(())
358            }
359            (
360                StreamingContentBlock::ToolResult { content, .. },
361                ContentBlockDelta::ToolResultContent(delta),
362            ) => {
363                *content = Some(match (content.take(), delta) {
364                    (_, ToolResultContent::Structured(value)) => {
365                        ToolResultContent::Structured(value)
366                    }
367                    (
368                        Some(ToolResultContent::Structured(value)),
369                        ToolResultContent::Text(delta),
370                    ) => ToolResultContent::Structured(merge_structured_text(value, delta)),
371                    (Some(ToolResultContent::Text(existing)), ToolResultContent::Text(delta)) => {
372                        ToolResultContent::Text(format!("{existing}{delta}"))
373                    }
374                    (None, delta) => delta,
375                });
376                Ok(())
377            }
378            (
379                StreamingContentBlock::HostedToolSearch { call, .. },
380                ContentBlockDelta::HostedToolSearchQuery(delta),
381            ) => {
382                let query = call.query.get_or_insert_with(String::new);
383                query.push_str(&delta);
384                Ok(())
385            }
386            (
387                StreamingContentBlock::HostedToolSearch { call, .. },
388                ContentBlockDelta::HostedToolSearchStatus(status),
389            ) => {
390                call.status = Some(status);
391                Ok(())
392            }
393            (
394                StreamingContentBlock::HostedWebSearch { call, .. },
395                ContentBlockDelta::HostedWebSearchAction(action),
396            ) => {
397                call.action = Some(action);
398                Ok(())
399            }
400            (
401                StreamingContentBlock::HostedWebSearch { call, .. },
402                ContentBlockDelta::HostedWebSearchStatus(status),
403            ) => {
404                call.status = Some(status);
405                Ok(())
406            }
407            (
408                StreamingContentBlock::ImageGeneration { call, .. },
409                ContentBlockDelta::ImageGenerationStatus(status),
410            ) => {
411                call.status = status;
412                Ok(())
413            }
414            (
415                StreamingContentBlock::ImageGeneration { call, .. },
416                ContentBlockDelta::ImageGenerationRevisedPrompt(delta),
417            ) => {
418                let revised_prompt = call.revised_prompt.get_or_insert_with(String::new);
419                revised_prompt.push_str(&delta);
420                Ok(())
421            }
422            (
423                StreamingContentBlock::ImageGeneration { call, .. },
424                ContentBlockDelta::ImageGenerationResult(result),
425            ) => {
426                call.result = Some(result);
427                Ok(())
428            }
429            (block, delta) => Err(ProviderError::MalformedStream(format!(
430                "delta {delta:?} is not valid for block {}",
431                block.kind_name()
432            ))),
433        }
434    }
435
436    fn mark_complete(&mut self) {
437        match self {
438            StreamingContentBlock::Text { complete, .. }
439            | StreamingContentBlock::Thinking { complete, .. }
440            | StreamingContentBlock::Image { complete, .. }
441            | StreamingContentBlock::ToolUse { complete, .. }
442            | StreamingContentBlock::ToolResult { complete, .. }
443            | StreamingContentBlock::HostedToolSearch { complete, .. }
444            | StreamingContentBlock::HostedWebSearch { complete, .. }
445            | StreamingContentBlock::ImageGeneration { complete, .. } => *complete = true,
446        }
447    }
448
449    fn is_complete(&self) -> bool {
450        match self {
451            StreamingContentBlock::Text { complete, .. }
452            | StreamingContentBlock::Thinking { complete, .. }
453            | StreamingContentBlock::Image { complete, .. }
454            | StreamingContentBlock::ToolUse { complete, .. }
455            | StreamingContentBlock::ToolResult { complete, .. }
456            | StreamingContentBlock::HostedToolSearch { complete, .. }
457            | StreamingContentBlock::HostedWebSearch { complete, .. }
458            | StreamingContentBlock::ImageGeneration { complete, .. } => *complete,
459        }
460    }
461
462    fn try_into_content_block(self) -> Result<ContentBlock, ProviderError> {
463        match self {
464            StreamingContentBlock::Text { text, .. } => Ok(ContentBlock::Text { text }),
465            StreamingContentBlock::Thinking {
466                thinking,
467                signature,
468                encrypted_content,
469                id,
470                provenance,
471                redacted,
472                ..
473            } => Ok(ContentBlock::Thinking {
474                thinking,
475                signature,
476                encrypted_content,
477                id,
478                provenance,
479                redacted,
480            }),
481            StreamingContentBlock::Image { source, .. } => Ok(ContentBlock::Image { source }),
482            StreamingContentBlock::ToolUse {
483                id,
484                name,
485                input_json,
486                ..
487            } => Ok(ContentBlock::ToolUse {
488                id,
489                name,
490                input: serde_json::from_str(&input_json).map_err(ProviderError::Deserialize)?,
491            }),
492            StreamingContentBlock::ToolResult {
493                tool_use_id,
494                content,
495                is_error,
496                ..
497            } => Ok(ContentBlock::ToolResult {
498                tool_use_id,
499                content: content.unwrap_or_else(|| ToolResultContent::text(String::new())),
500                is_error,
501            }),
502            StreamingContentBlock::HostedToolSearch { call, .. } => {
503                Ok(ContentBlock::HostedToolSearch { call })
504            }
505            StreamingContentBlock::HostedWebSearch { call, .. } => {
506                Ok(ContentBlock::HostedWebSearch { call })
507            }
508            StreamingContentBlock::ImageGeneration { call, .. } => {
509                Ok(ContentBlock::ImageGeneration { call })
510            }
511        }
512    }
513
514    fn kind_name(&self) -> &'static str {
515        match self {
516            StreamingContentBlock::Text { .. } => "text",
517            StreamingContentBlock::Thinking { .. } => "thinking",
518            StreamingContentBlock::Image { .. } => "image",
519            StreamingContentBlock::ToolUse { .. } => "tool_use",
520            StreamingContentBlock::ToolResult { .. } => "tool_result",
521            StreamingContentBlock::HostedToolSearch { .. } => "hosted_tool_search",
522            StreamingContentBlock::HostedWebSearch { .. } => "hosted_web_search",
523            StreamingContentBlock::ImageGeneration { .. } => "image_generation",
524        }
525    }
526}
527
528impl From<ContentBlockStart> for StreamingContentBlock {
529    fn from(value: ContentBlockStart) -> Self {
530        match value {
531            ContentBlockStart::Text => StreamingContentBlock::Text {
532                text: String::new(),
533                complete: false,
534            },
535            ContentBlockStart::Thinking {
536                encrypted_content,
537                id,
538                provenance,
539                redacted,
540            } => StreamingContentBlock::Thinking {
541                thinking: String::new(),
542                signature: None,
543                encrypted_content,
544                id,
545                provenance,
546                redacted,
547                complete: false,
548            },
549            ContentBlockStart::Image { source } => StreamingContentBlock::Image {
550                source,
551                complete: false,
552            },
553            ContentBlockStart::ToolUse { id, name } => StreamingContentBlock::ToolUse {
554                id,
555                name,
556                input_json: String::new(),
557                complete: false,
558            },
559            ContentBlockStart::ToolResult {
560                tool_use_id,
561                is_error,
562                content,
563            } => StreamingContentBlock::ToolResult {
564                tool_use_id,
565                content,
566                is_error,
567                complete: false,
568            },
569            ContentBlockStart::HostedToolSearch { call } => {
570                StreamingContentBlock::HostedToolSearch {
571                    call,
572                    complete: false,
573                }
574            }
575            ContentBlockStart::HostedWebSearch { call } => StreamingContentBlock::HostedWebSearch {
576                call,
577                complete: false,
578            },
579            ContentBlockStart::ImageGeneration { call } => StreamingContentBlock::ImageGeneration {
580                call,
581                complete: false,
582            },
583        }
584    }
585}
586
587impl ContentBlock {
588    fn into_provider_events(self, index: usize) -> Vec<ProviderEvent> {
589        match self {
590            ContentBlock::Text { text } => {
591                let mut events = vec![ProviderEvent::ContentBlockStarted {
592                    index,
593                    kind: ContentBlockStart::Text,
594                }];
595                if !text.is_empty() {
596                    events.push(ProviderEvent::ContentBlockDelta {
597                        index,
598                        delta: ContentBlockDelta::Text(text),
599                    });
600                }
601                events.push(ProviderEvent::ContentBlockStopped { index });
602                events
603            }
604            ContentBlock::Thinking {
605                thinking,
606                signature,
607                encrypted_content,
608                id,
609                provenance,
610                redacted,
611            } => {
612                let mut events = vec![ProviderEvent::ContentBlockStarted {
613                    index,
614                    kind: ContentBlockStart::Thinking {
615                        encrypted_content,
616                        id,
617                        provenance,
618                        redacted,
619                    },
620                }];
621                if !thinking.is_empty() {
622                    events.push(ProviderEvent::ContentBlockDelta {
623                        index,
624                        delta: ContentBlockDelta::ThinkingText(thinking),
625                    });
626                }
627                if let Some(signature) = signature {
628                    events.push(ProviderEvent::ContentBlockDelta {
629                        index,
630                        delta: ContentBlockDelta::ThinkingSignature(signature),
631                    });
632                }
633                events.push(ProviderEvent::ContentBlockStopped { index });
634                events
635            }
636            ContentBlock::Image { source } => vec![
637                ProviderEvent::ContentBlockStarted {
638                    index,
639                    kind: ContentBlockStart::Image { source },
640                },
641                ProviderEvent::ContentBlockStopped { index },
642            ],
643            ContentBlock::ToolUse { id, name, input } => {
644                let mut events = vec![ProviderEvent::ContentBlockStarted {
645                    index,
646                    kind: ContentBlockStart::ToolUse { id, name },
647                }];
648                let input_json = input.to_string();
649                if !input_json.is_empty() {
650                    events.push(ProviderEvent::ContentBlockDelta {
651                        index,
652                        delta: ContentBlockDelta::ToolUseInputJson(input_json),
653                    });
654                }
655                events.push(ProviderEvent::ContentBlockStopped { index });
656                events
657            }
658            ContentBlock::ToolResult {
659                tool_use_id,
660                content,
661                is_error,
662            } => {
663                let mut events = vec![ProviderEvent::ContentBlockStarted {
664                    index,
665                    kind: ContentBlockStart::ToolResult {
666                        tool_use_id,
667                        is_error,
668                        content: Some(content.clone()),
669                    },
670                }];
671                events.push(ProviderEvent::ContentBlockStopped { index });
672                events
673            }
674            ContentBlock::HostedToolSearch { call } => {
675                vec![
676                    ProviderEvent::ContentBlockStarted {
677                        index,
678                        kind: ContentBlockStart::HostedToolSearch { call },
679                    },
680                    ProviderEvent::ContentBlockStopped { index },
681                ]
682            }
683            ContentBlock::HostedWebSearch { call } => {
684                vec![
685                    ProviderEvent::ContentBlockStarted {
686                        index,
687                        kind: ContentBlockStart::HostedWebSearch { call },
688                    },
689                    ProviderEvent::ContentBlockStopped { index },
690                ]
691            }
692            ContentBlock::ImageGeneration { call } => {
693                vec![
694                    ProviderEvent::ContentBlockStarted {
695                        index,
696                        kind: ContentBlockStart::ImageGeneration { call },
697                    },
698                    ProviderEvent::ContentBlockStopped { index },
699                ]
700            }
701        }
702    }
703}
704
705fn merge_structured_text(value: serde_json::Value, delta: String) -> serde_json::Value {
706    match value {
707        serde_json::Value::String(existing) => {
708            serde_json::Value::String(format!("{existing}{delta}"))
709        }
710        other => other,
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn reported_zero_usage() -> TokenUsage {
719        TokenUsage {
720            input_tokens: Some(0),
721            output_tokens: Some(0),
722            total_tokens: Some(0),
723            cache_read_input_tokens: Some(0),
724            cache_creation_input_tokens: Some(0),
725            reasoning_tokens: Some(0),
726            thoughts_tokens: Some(0),
727            tool_input_tokens: Some(0),
728        }
729    }
730
731    #[tokio::test]
732    async fn response_round_trip_preserves_usage() {
733        let response = Response {
734            id: "resp-1".to_string(),
735            model: "model".to_string(),
736            role: Role::Assistant,
737            content: vec![ContentBlock::text("hello")],
738            stop_reason: Some("stop".to_string()),
739            usage: Some(TokenUsage {
740                input_tokens: Some(10),
741                output_tokens: Some(3),
742                total_tokens: Some(13),
743                cache_read_input_tokens: Some(2),
744                cache_creation_input_tokens: None,
745                reasoning_tokens: Some(1),
746                thoughts_tokens: None,
747                tool_input_tokens: None,
748            }),
749        };
750
751        let rebuilt =
752            collect_response_from_stream(provider_event_stream_from_response(response.clone()))
753                .await
754                .expect("response should rebuild");
755
756        assert_eq!(rebuilt, response);
757    }
758
759    #[tokio::test]
760    async fn response_round_trip_preserves_signed_and_redacted_thinking() {
761        let provenance = ReasoningProvenance {
762            provider: crate::ProviderId::new("anthropic-edge"),
763            model: "claude-test".to_string(),
764            format: crate::ReasoningFormat::AnthropicSigned,
765        };
766        let response = Response {
767            id: "resp-thinking".to_string(),
768            model: "claude-test".to_string(),
769            role: Role::Assistant,
770            content: vec![
771                ContentBlock::Thinking {
772                    thinking: "private chain".to_string(),
773                    signature: Some("opaque-signature".to_string()),
774                    encrypted_content: None,
775                    id: None,
776                    provenance: Some(provenance.clone()),
777                    redacted: false,
778                },
779                ContentBlock::Thinking {
780                    thinking: String::new(),
781                    signature: Some("opaque-redacted-data".to_string()),
782                    encrypted_content: None,
783                    id: None,
784                    provenance: Some(provenance),
785                    redacted: true,
786                },
787            ],
788            stop_reason: Some("end_turn".to_string()),
789            usage: None,
790        };
791
792        let rebuilt =
793            collect_response_from_stream(provider_event_stream_from_response(response.clone()))
794                .await
795                .expect("thinking response should rebuild");
796
797        assert_eq!(rebuilt, response);
798    }
799
800    #[tokio::test]
801    async fn response_round_trip_preserves_hosted_actions_and_structured_tool_results() {
802        let response = Response {
803            id: "resp-2".to_string(),
804            model: "model".to_string(),
805            role: Role::Assistant,
806            content: vec![
807                ContentBlock::ToolResult {
808                    tool_use_id: "call-1".to_string(),
809                    content: ToolResultContent::Structured(serde_json::json!({"ok":true})),
810                    is_error: false,
811                },
812                ContentBlock::HostedToolSearch {
813                    call: HostedToolSearchCall {
814                        id: "search-1".to_string(),
815                        status: Some("completed".to_string()),
816                        query: Some("weather".to_string()),
817                    },
818                },
819                ContentBlock::HostedWebSearch {
820                    call: HostedWebSearchCall {
821                        id: "web-1".to_string(),
822                        status: Some("completed".to_string()),
823                        action: Some(crate::model::WebSearchAction::Search {
824                            query: Some("weather".to_string()),
825                            queries: None,
826                        }),
827                    },
828                },
829                ContentBlock::ImageGeneration {
830                    call: ImageGenerationCall {
831                        id: "image-1".to_string(),
832                        status: "completed".to_string(),
833                        revised_prompt: Some("A blue square".to_string()),
834                        result: Some(crate::model::ImageGenerationResult::ArtifactRef {
835                            artifact_id: "artifact-1".to_string(),
836                        }),
837                    },
838                },
839            ],
840            stop_reason: Some("stop".to_string()),
841            usage: None,
842        };
843
844        let rebuilt =
845            collect_response_from_stream(provider_event_stream_from_response(response.clone()))
846                .await
847                .expect("response should rebuild");
848
849        assert_eq!(rebuilt, response);
850    }
851
852    #[test]
853    fn response_into_compaction_response_preserves_text_and_every_usage_field() {
854        let usage = TokenUsage {
855            input_tokens: Some(101),
856            output_tokens: Some(23),
857            total_tokens: Some(124),
858            cache_read_input_tokens: Some(17),
859            cache_creation_input_tokens: Some(5),
860            reasoning_tokens: Some(11),
861            thoughts_tokens: Some(7),
862            tool_input_tokens: Some(3),
863        };
864        let response = Response {
865            id: "resp-3".to_string(),
866            model: "model".to_string(),
867            role: Role::Assistant,
868            content: vec![
869                ContentBlock::text("first"),
870                ContentBlock::ToolResult {
871                    tool_use_id: "call-1".to_string(),
872                    content: ToolResultContent::text("ignored"),
873                    is_error: false,
874                },
875                ContentBlock::text("second"),
876            ],
877            stop_reason: None,
878            usage: Some(usage.clone()),
879        };
880
881        let compaction = response.into_compaction_response();
882
883        assert_eq!(
884            compaction.output,
885            vec![CompactionInputItem::CompactionSummary {
886                content: "first\nsecond".to_string(),
887            }]
888        );
889        assert_eq!(compaction.usage, Some(usage));
890    }
891
892    #[test]
893    fn compaction_response_distinguishes_absent_from_reported_zero_usage() {
894        let response = |usage| Response {
895            id: "resp-usage".to_string(),
896            model: "model".to_string(),
897            role: Role::Assistant,
898            content: vec![ContentBlock::text("summary")],
899            stop_reason: None,
900            usage,
901        };
902
903        let absent = response(None).into_compaction_response();
904        let reported_empty = response(Some(TokenUsage::default())).into_compaction_response();
905        let zero_usage = reported_zero_usage();
906        let reported_zero = response(Some(zero_usage.clone())).into_compaction_response();
907
908        assert_eq!(absent.usage, None);
909        assert_eq!(reported_empty.usage, Some(TokenUsage::default()));
910        assert_eq!(reported_zero.usage, Some(zero_usage));
911    }
912
913    #[test]
914    fn compaction_response_usage_is_backward_compatible_in_serde() {
915        let legacy: CompactionResponse = serde_json::from_value(serde_json::json!({
916            "output": []
917        }))
918        .expect("a response serialized before usage existed should load");
919
920        assert_eq!(legacy.usage, None);
921        assert_eq!(
922            serde_json::to_value(&legacy).expect("serialize response without usage"),
923            serde_json::json!({ "output": [] }),
924            "absent usage stays omitted on the wire"
925        );
926
927        let reported_zero = CompactionResponse {
928            output: Vec::new(),
929            usage: Some(reported_zero_usage()),
930        };
931        assert!(
932            serde_json::to_value(reported_zero)
933                .expect("serialize reported zero usage")
934                .get("usage")
935                .is_some(),
936            "a provider-reported zero must remain distinguishable from absence"
937        );
938    }
939
940    #[test]
941    fn memory_summarize_response_from_text_accepts_json_array() {
942        let response = MemorySummarizeResponse::from_text(
943            r#"[{"raw_memory":"Detailed summary","memory_summary":"Short summary"}]"#,
944        )
945        .expect("memory summarize response should parse");
946
947        assert_eq!(
948            response,
949            MemorySummarizeResponse {
950                output: vec![MemorySummarizeOutput {
951                    raw_memory: "Detailed summary".to_string(),
952                    memory_summary: "Short summary".to_string(),
953                }],
954            }
955        );
956    }
957
958    #[test]
959    fn memory_summarize_response_from_text_accepts_markdown_fence_and_trace_alias() {
960        let response = MemorySummarizeResponse::from_text(
961            "```json\n[{\"trace_summary\":\"Detailed summary\",\"memory_summary\":\"Short summary\"}]\n```",
962        )
963        .expect("memory summarize response should parse");
964
965        assert_eq!(
966            response.output[0],
967            MemorySummarizeOutput {
968                raw_memory: "Detailed summary".to_string(),
969                memory_summary: "Short summary".to_string(),
970            }
971        );
972    }
973
974    #[test]
975    fn response_into_memory_summarize_response_collects_text_content() {
976        let response = Response {
977            id: "resp-4".to_string(),
978            model: "model".to_string(),
979            role: Role::Assistant,
980            content: vec![ContentBlock::text(
981                "[{\"raw_memory\":\"Detailed summary\",\"memory_summary\":\"Short summary\"}]",
982            )],
983            stop_reason: None,
984            usage: None,
985        };
986
987        let summarize = response
988            .into_memory_summarize_response()
989            .expect("memory summarize response should parse");
990
991        assert_eq!(summarize.output.len(), 1);
992        assert_eq!(summarize.output[0].raw_memory, "Detailed summary");
993        assert_eq!(summarize.output[0].memory_summary, "Short summary");
994    }
995}