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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct CompactionResponse {
33 pub output: Vec<CompactionInputItem>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub usage: Option<TokenUsage>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct MemorySummarizeResponse {
45 pub output: Vec<MemorySummarizeOutput>,
46}
47
48#[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
56pub 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
69pub 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 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 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 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}