1use std::sync::Arc;
14
15use agent_client_protocol::schema::v1::{
16 ContentBlock, ContentChunk, EmbeddedResourceResource, PlanEntryStatus, ToolCallStatus, ToolKind,
17};
18use anyhow::{Result, bail};
19use serde::{Deserialize, Serialize};
20
21pub const SESSION_RESTART_TEXT: &str = "[session restarted]";
22pub const SESSION_RESTART_ITEM_PREFIX: &str = "system:session-restarted:";
23pub const HARNESS_TURN_TEXT: &str = "Agent continued on its own";
25pub const HARNESS_TURN_ITEM_PREFIX: &str = "harness-turn:";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ToolSummarySourceKind {
31 RawInput,
32 RawOutput,
33 Title,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ToolCallPresentation {
39 pub summary: String,
40 pub source: String,
41 pub source_kind: ToolSummarySourceKind,
42 pub tool_kind: ToolKind,
43 #[serde(default)]
46 pub summary_version: u8,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum TranscriptBody {
55 User {
56 content: Vec<serde_json::Value>,
57 },
58 Agent {
59 chunks: Vec<serde_json::Value>,
62 streaming: bool,
63 },
64 Thought {
65 chunks: Vec<serde_json::Value>,
68 streaming: bool,
69 },
70 Tool {
71 call: serde_json::Value,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 terminal_outputs: Vec<TerminalOutputRecord>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 terminal_refs: Vec<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 presentation: Option<Box<ToolCallPresentation>>,
90 },
91 TerminalOutput {
96 record: TerminalOutputRecord,
97 },
98 Plan {
99 plan: serde_json::Value,
102 },
103 PlanProposal {
109 proposal_id: String,
111 plan: String,
113 },
114 System {
115 text: String,
116 },
117}
118
119pub fn push_content_chunk(chunks: &mut Vec<serde_json::Value>, chunk: serde_json::Value) {
134 if chunks
135 .last()
136 .is_some_and(|last| text_chunks_mergeable(last, &chunk))
137 {
138 let addition = chunk
139 .get("content")
140 .and_then(|content| content.get("text"))
141 .and_then(serde_json::Value::as_str)
142 .unwrap_or_default()
143 .to_owned();
144 if let Some(serde_json::Value::Object(last)) = chunks.last_mut()
145 && let Some(serde_json::Value::Object(content)) = last.get_mut("content")
146 && let Some(serde_json::Value::String(text)) = content.get_mut("text")
147 {
148 text.push_str(&addition);
149 return;
150 }
151 }
152 chunks.push(chunk);
153}
154
155pub fn coalesce_content_chunks(chunks: &mut Vec<serde_json::Value>) {
161 if chunks.len() < 2 {
162 return;
163 }
164 let mut merged = Vec::with_capacity(chunks.len());
165 for chunk in std::mem::take(chunks) {
166 push_content_chunk(&mut merged, chunk);
167 }
168 merged.shrink_to_fit();
169 *chunks = merged;
170}
171
172fn text_chunks_mergeable(last: &serde_json::Value, next: &serde_json::Value) -> bool {
176 let (serde_json::Value::Object(last), serde_json::Value::Object(next)) = (last, next) else {
177 return false;
178 };
179 let (
180 Some(serde_json::Value::Object(last_content)),
181 Some(serde_json::Value::Object(next_content)),
182 ) = (last.get("content"), next.get("content"))
183 else {
184 return false;
185 };
186 let is_text = |content: &serde_json::Map<String, serde_json::Value>| {
187 content.get("type").and_then(serde_json::Value::as_str) == Some("text")
188 && content
189 .get("text")
190 .is_some_and(serde_json::Value::is_string)
191 };
192 if !is_text(last_content) || !is_text(next_content) {
193 return false;
194 }
195 if last.len() != next.len()
197 || !last
198 .iter()
199 .all(|(key, value)| key == "content" || next.get(key) == Some(value))
200 {
201 return false;
202 }
203 last_content.len() == next_content.len()
205 && last_content
206 .iter()
207 .all(|(key, value)| key == "text" || next_content.get(key) == Some(value))
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct TerminalOutputRecord {
215 pub terminal_id: String,
216 pub output: String,
217 #[serde(default, skip_serializing_if = "is_false")]
218 pub truncated: bool,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub exit_code: Option<u32>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub signal: Option<String>,
223}
224
225impl TerminalOutputRecord {
226 pub fn exited_cleanly(&self) -> bool {
231 self.exit_code == Some(0) && self.signal.is_none()
232 }
233
234 pub fn matches_tool_raw_result(&self, call: &serde_json::Value) -> bool {
239 if !matches!(
240 call.get("status").and_then(serde_json::Value::as_str),
241 Some("completed" | "failed")
242 ) {
243 return false;
244 }
245 let Some(raw) = call.get("rawOutput") else {
246 return false;
247 };
248 let Some(exit_code) = raw
249 .get("exit_code")
250 .and_then(serde_json::Value::as_u64)
251 .and_then(|code| u32::try_from(code).ok())
252 else {
253 return false;
254 };
255 if self.exit_code != Some(exit_code) || self.signal.is_some() {
256 return false;
257 }
258 match raw.get("output") {
259 Some(serde_json::Value::Array(bytes)) => {
260 bytes.len() == self.output.len()
261 && bytes
262 .iter()
263 .zip(self.output.as_bytes())
264 .all(|(value, byte)| value.as_u64() == Some(u64::from(*byte)))
265 }
266 Some(serde_json::Value::String(output)) => output == &self.output,
267 _ => false,
268 }
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub struct TranscriptItem {
275 pub stable_id: String,
276 pub position: u64,
278 pub latest_content_event_ordinal: Option<u64>,
281 pub created_at_ms: i64,
282 pub last_changed_at_ms: i64,
283 pub body: TranscriptBody,
284}
285
286impl TranscriptItem {
287 pub fn is_session_restart(&self) -> bool {
288 self.stable_id.starts_with(SESSION_RESTART_ITEM_PREFIX)
289 }
290
291 pub fn seq(&self) -> u64 {
299 self.latest_content_event_ordinal.unwrap_or(self.position)
300 }
301
302 pub fn is_turn_start(&self) -> bool {
306 matches!(self.body, TranscriptBody::User { .. })
307 || self.stable_id.starts_with(HARNESS_TURN_ITEM_PREFIX)
308 }
309
310 pub fn is_nonempty_agent_message(&self) -> bool {
311 let TranscriptBody::Agent { chunks, .. } = &self.body else {
312 return false;
313 };
314 chunks.iter().any(|chunk| {
315 let Some(content) = chunk.get("content") else {
316 return false;
317 };
318 match content.get("type").and_then(serde_json::Value::as_str) {
319 Some("text") => content
320 .get("text")
321 .and_then(serde_json::Value::as_str)
322 .is_some_and(|text| !text.trim().is_empty()),
323 Some(_) => true,
324 None => false,
325 }
326 })
327 }
328
329 pub fn validate(&self, through: u64) -> Result<()> {
330 if self.stable_id.trim().is_empty() {
331 bail!("materialized transcript item has an empty stable id");
332 }
333 if self.position == 0 || self.position > through {
334 bail!(
335 "materialized transcript item {:?} has invalid position {} at frontier {through}",
336 self.stable_id,
337 self.position
338 );
339 }
340 match (&self.body, self.latest_content_event_ordinal) {
341 (TranscriptBody::Agent { .. }, Some(ordinal))
342 if ordinal >= self.position && ordinal <= through => {}
343 (TranscriptBody::Agent { .. }, Some(ordinal)) => bail!(
344 "materialized agent message {:?} has invalid latest content ordinal {ordinal} at position {} and frontier {through}",
345 self.stable_id,
346 self.position
347 ),
348 (TranscriptBody::Agent { .. }, None) => bail!(
349 "materialized agent message {:?} has no latest content ordinal",
350 self.stable_id
351 ),
352 (_, Some(ordinal)) => bail!(
353 "non-agent transcript item {:?} has latest content ordinal {ordinal}",
354 self.stable_id
355 ),
356 (_, None) => {}
357 }
358 if self.last_changed_at_ms < self.created_at_ms {
359 bail!(
360 "materialized transcript item {:?} changed before it was created",
361 self.stable_id
362 );
363 }
364 Ok(())
365 }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
369pub enum ChatRole {
370 User,
371 Agent,
372 Thought,
374 Tool,
376 Plan,
378 PlanProposal,
380 System,
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
384pub struct ChatEntry {
385 #[serde(default)]
386 pub start_seq: u64,
387 pub seq: u64,
388 pub role: ChatRole,
389 pub text: String,
390 pub recorded_at_ms: Option<i64>,
391 pub revision: u64,
392 pub message_id: Option<String>,
393 pub tool_call_id: Option<String>,
394 pub tool_status: Option<ToolStatus>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub tool_summary: Option<String>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub tool_presentation: Option<ToolCallPresentation>,
403 pub tool_content: Vec<String>,
404 pub tool_diffstats: Vec<String>,
405 pub tool_locations: Vec<String>,
406 pub plan: Vec<PlanLine>,
407 #[serde(default, skip_serializing_if = "is_false")]
408 pub leading_omitted: bool,
409 #[serde(default, skip_serializing_if = "is_false")]
413 pub raw_only: bool,
414 #[serde(skip)]
418 pub source: TranscriptSource,
419}
420
421#[derive(Debug, Clone, Default)]
428pub struct TranscriptSource(pub Option<Arc<TranscriptItem>>);
429
430impl TranscriptSource {
431 pub fn is(&self, item: &Arc<TranscriptItem>) -> bool {
432 self.0
433 .as_ref()
434 .is_some_and(|source| Arc::ptr_eq(source, item))
435 }
436}
437
438impl PartialEq for TranscriptSource {
439 fn eq(&self, _other: &Self) -> bool {
440 true
441 }
442}
443
444impl Eq for TranscriptSource {}
445
446impl ChatEntry {
447 pub fn is_session_restart(&self) -> bool {
452 self.source
453 .0
454 .as_ref()
455 .is_some_and(|item| item.is_session_restart())
456 || (self.role == ChatRole::System && self.text == SESSION_RESTART_TEXT)
457 }
458
459 pub fn plan(seq: u64, plan: Vec<PlanLine>) -> Self {
460 Self {
461 start_seq: seq,
462 seq,
463 role: ChatRole::Plan,
464 text: String::new(),
465 recorded_at_ms: None,
466 revision: 0,
467 message_id: None,
468 tool_call_id: None,
469 tool_status: None,
470 tool_summary: None,
471 tool_presentation: None,
472 tool_content: Vec::new(),
473 tool_diffstats: Vec::new(),
474 tool_locations: Vec::new(),
475 plan,
476 leading_omitted: false,
477 raw_only: false,
478 source: TranscriptSource::default(),
479 }
480 }
481
482 pub fn touch(&mut self, seq: u64) {
483 self.seq = seq;
484 self.revision = self.revision.wrapping_add(1);
485 }
486
487 #[doc(hidden)]
493 pub fn bounded_for_dashboard(mut self) -> Self {
494 self.bound_dashboard_content();
495 self
496 }
497
498 fn bound_dashboard_content(&mut self) {
499 const TEXT_BYTES: usize = 64 * 1024;
500 const DETAIL_BYTES: usize = 2 * 1024;
501 const DETAIL_COUNT: usize = 8;
502
503 self.leading_omitted |= truncate_string_start(&mut self.text, TEXT_BYTES);
504 for values in [
505 &mut self.tool_content,
506 &mut self.tool_diffstats,
507 &mut self.tool_locations,
508 ] {
509 values.truncate(DETAIL_COUNT);
510 for value in values {
511 truncate_string_start(value, DETAIL_BYTES);
512 }
513 }
514 if let Some(summary) = &mut self.tool_summary {
515 truncate_string_start(summary, DETAIL_BYTES);
516 }
517 if let Some(presentation) = &mut self.tool_presentation {
518 truncate_string_start(&mut presentation.summary, DETAIL_BYTES);
519 truncate_string_start(&mut presentation.source, TEXT_BYTES);
520 }
521 self.plan.truncate(DETAIL_COUNT);
522 for line in &mut self.plan {
523 truncate_string_start(&mut line.text, DETAIL_BYTES);
524 }
525 }
526
527 pub fn with_recorded_at(mut self, recorded_at_ms: Option<i64>) -> Self {
528 self.recorded_at_ms = recorded_at_ms;
529 self
530 }
531}
532
533impl ChatEntry {
536 pub fn plain(seq: u64, role: ChatRole, text: impl Into<String>) -> Self {
537 Self {
538 start_seq: seq,
539 seq,
540 role,
541 text: sanitize_terminal_text(&text.into()),
542 recorded_at_ms: None,
543 revision: 0,
544 message_id: None,
545 tool_call_id: None,
546 tool_status: None,
547 tool_summary: None,
548 tool_presentation: None,
549 tool_content: Vec::new(),
550 tool_diffstats: Vec::new(),
551 tool_locations: Vec::new(),
552 plan: Vec::new(),
553 leading_omitted: false,
554 raw_only: false,
555 source: TranscriptSource::default(),
556 }
557 }
558
559 pub fn tool(
560 seq: u64,
561 title: impl Into<String>,
562 tool_call_id: Option<String>,
563 tool_status: ToolStatus,
564 ) -> Self {
565 Self {
566 start_seq: seq,
567 seq,
568 role: ChatRole::Tool,
569 text: sanitize_terminal_text(&title.into()),
570 recorded_at_ms: None,
571 revision: 0,
572 message_id: None,
573 tool_call_id,
574 tool_status: Some(tool_status),
575 tool_summary: None,
576 tool_presentation: None,
577 tool_content: Vec::new(),
578 tool_diffstats: Vec::new(),
579 tool_locations: Vec::new(),
580 plan: Vec::new(),
581 leading_omitted: false,
582 raw_only: false,
583 source: TranscriptSource::default(),
584 }
585 }
586}
587
588pub(crate) fn is_false(value: &bool) -> bool {
589 !*value
590}
591
592pub fn plan_status(status: &PlanEntryStatus) -> PlanStatus {
593 match status {
594 PlanEntryStatus::InProgress => PlanStatus::Running,
595 PlanEntryStatus::Completed => PlanStatus::Completed,
596 _ => PlanStatus::Pending,
597 }
598}
599
600pub fn sanitize_terminal_text(text: &str) -> String {
602 let mut sanitized = String::with_capacity(text.len());
603 let mut chars = text.chars().peekable();
604 while let Some(ch) = chars.next() {
605 if ch == '\x1b' {
606 while consume_escape_body(&mut chars) {}
610 } else if ch == '\r' {
611 if chars.peek() != Some(&'\n') {
612 sanitized.push('\n');
613 }
614 } else if matches!(ch, '\n' | '\t') || !ch.is_control() {
615 sanitized.push(ch);
616 }
617 }
618 sanitized
619}
620
621fn consume_escape_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
628 match chars.next() {
629 Some('[') => {
631 let _ = chars.find(|ch| ('@'..='~').contains(ch));
632 false
633 }
634 Some(']' | 'P' | 'X' | '^' | '_') => consume_string_body(chars),
636 Some('(' | ')' | '*' | '+' | '-' | '.' | '/' | '#' | '%' | ' ') => {
638 chars.next();
639 false
640 }
641 _ => false,
644 }
645}
646
647fn consume_string_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
651 while let Some(&ch) = chars.peek() {
652 match ch {
653 '\n' | '\r' | '\x18' | '\x1a' => return false,
654 '\x07' => {
655 chars.next();
656 return false;
657 }
658 '\x1b' => {
659 chars.next();
660 return true;
661 }
662 _ => {
663 chars.next();
664 }
665 }
666 }
667 false
668}
669
670pub fn materialized_content_text(content: &[serde_json::Value]) -> String {
671 let text = content
672 .iter()
673 .map(materialized_value_text)
674 .filter(|text| !text.is_empty())
675 .collect::<Vec<_>>()
676 .join("\n");
677 crate::relay::strip_hidden_prompt_context(&text).to_owned()
678}
679
680#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "snake_case")]
687pub enum TranscriptRole {
688 User,
689 Agent,
690 Thought,
691 Tool,
692 Terminal,
693 Plan,
694 PlanProposal,
695 System,
696}
697
698impl TranscriptRole {
699 pub fn as_str(self) -> &'static str {
700 match self {
701 Self::User => "user",
702 Self::Agent => "agent",
703 Self::Thought => "thought",
704 Self::Tool => "tool",
705 Self::Terminal => "terminal",
706 Self::Plan => "plan",
707 Self::PlanProposal => "plan_proposal",
708 Self::System => "system",
709 }
710 }
711 pub fn storage_kind(self) -> &'static str {
712 match self {
713 Self::Terminal => "terminal_output",
714 other => other.as_str(),
715 }
716 }
717}
718
719pub fn transcript_item_role(body: &TranscriptBody) -> &'static str {
720 let role = match body {
721 TranscriptBody::User { .. } => TranscriptRole::User,
722 TranscriptBody::Agent { .. } => TranscriptRole::Agent,
723 TranscriptBody::Thought { .. } => TranscriptRole::Thought,
724 TranscriptBody::Tool { .. } => TranscriptRole::Tool,
725 TranscriptBody::TerminalOutput { .. } => TranscriptRole::Terminal,
726 TranscriptBody::Plan { .. } => TranscriptRole::Plan,
727 TranscriptBody::PlanProposal { .. } => TranscriptRole::PlanProposal,
728 TranscriptBody::System { .. } => TranscriptRole::System,
729 };
730 role.as_str()
731}
732
733pub fn materialized_chunks_text(chunks: &[serde_json::Value]) -> String {
734 chunks
735 .iter()
736 .filter_map(|value| match ContentChunk::deserialize(value) {
737 Ok(chunk) => Some(chunk),
738 Err(error) => {
739 tracing::warn!(%error, "could not decode a stored content chunk");
740 None
741 }
742 })
743 .filter_map(|chunk| content_block_text(&chunk.content))
744 .map(|text| sanitize_terminal_text(&text))
745 .collect::<Vec<_>>()
746 .join("")
747}
748
749fn materialized_value_text(value: &serde_json::Value) -> String {
750 if let Ok(block) = ContentBlock::deserialize(value)
751 && let Some(text) = content_block_text(&block)
752 {
753 return sanitize_terminal_text(&text);
754 }
755 if let Some(text) = value.as_str() {
756 return sanitize_terminal_text(text);
757 }
758 sanitize_terminal_text(&serde_json::to_string(value).unwrap_or_else(|_| "[content]".into()))
759}
760
761pub fn tool_status(status: &ToolCallStatus) -> ToolStatus {
762 match status {
763 ToolCallStatus::InProgress => ToolStatus::Running,
764 ToolCallStatus::Completed => ToolStatus::Completed,
765 ToolCallStatus::Failed => ToolStatus::Failed,
766 _ => ToolStatus::Pending,
767 }
768}
769
770pub fn content_block_text(content: &ContentBlock) -> Option<String> {
771 match content {
772 ContentBlock::Text(text) => Some(text.text.clone()),
773 ContentBlock::Image(_) => Some("[image]".into()),
774 ContentBlock::Audio(_) => Some("[audio]".into()),
775 ContentBlock::ResourceLink(link) => Some(format!("[{}]({})", link.name, link.uri)),
776 ContentBlock::Resource(resource) => Some(match &resource.resource {
777 EmbeddedResourceResource::TextResourceContents(resource) => resource.text.clone(),
778 EmbeddedResourceResource::BlobResourceContents(resource) => {
779 format!("[embedded resource: {}]", resource.uri)
780 }
781 _ => "[embedded resource]".into(),
782 }),
783 _ => None,
784 }
785}
786
787fn truncate_string_start(value: &mut String, maximum_bytes: usize) -> bool {
788 if value.len() <= maximum_bytes {
789 return false;
790 }
791 let mut start = value.len() - maximum_bytes;
792 while !value.is_char_boundary(start) {
793 start += 1;
794 }
795 value.drain(..start);
796 true
797}
798
799#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
801pub enum ToolStatus {
802 Pending,
803 Running,
804 Completed,
805 Failed,
806}
807
808#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
809pub enum PlanStatus {
810 Pending,
811 Running,
812 Completed,
813}
814
815#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
816pub struct PlanLine {
817 pub text: String,
818 pub status: PlanStatus,
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824 use serde_json::json;
825
826 fn text_chunk(text: &str, message_id: Option<&str>) -> serde_json::Value {
827 match message_id {
828 Some(id) => json!({"content": {"type": "text", "text": text}, "messageId": id}),
829 None => json!({"content": {"type": "text", "text": text}}),
830 }
831 }
832
833 #[test]
834 fn push_content_chunk_merges_adjacent_text_for_the_same_message_id() {
835 let mut chunks = vec![text_chunk("The", Some("m1"))];
836 push_content_chunk(&mut chunks, text_chunk(" quick", Some("m1")));
837 push_content_chunk(&mut chunks, text_chunk(" fox", Some("m1")));
838 assert_eq!(chunks, vec![text_chunk("The quick fox", Some("m1"))]);
839 }
840
841 #[test]
842 fn push_content_chunk_merges_adjacent_text_without_message_ids() {
843 let mut chunks = vec![text_chunk("one", None)];
844 push_content_chunk(&mut chunks, text_chunk(" two", None));
845 assert_eq!(chunks, vec![text_chunk("one two", None)]);
846 }
847
848 #[test]
849 fn push_content_chunk_keeps_chunks_from_different_message_ids_apart() {
850 let mut chunks = vec![text_chunk("first", Some("m1"))];
851 push_content_chunk(&mut chunks, text_chunk("second", Some("m2")));
852 push_content_chunk(&mut chunks, text_chunk("third", None));
853 assert_eq!(
854 chunks,
855 vec![
856 text_chunk("first", Some("m1")),
857 text_chunk("second", Some("m2")),
858 text_chunk("third", None),
859 ]
860 );
861 }
862
863 #[test]
864 fn push_content_chunk_keeps_non_text_content_separate() {
865 let image = json!({"content": {"type": "image", "data": "abc", "mimeType": "image/png"}});
866 let mut chunks = vec![text_chunk("before", None)];
867 push_content_chunk(&mut chunks, image.clone());
868 push_content_chunk(&mut chunks, image.clone());
869 push_content_chunk(&mut chunks, text_chunk("after", None));
870 assert_eq!(
871 chunks,
872 vec![
873 text_chunk("before", None),
874 image.clone(),
875 image,
876 text_chunk("after", None),
877 ]
878 );
879 }
880
881 #[test]
882 fn push_content_chunk_keeps_chunks_with_differing_metadata_apart() {
883 let mut chunks =
884 vec![json!({"content": {"type": "text", "text": "a"}, "meta": {"source": "one"}})];
885 push_content_chunk(
886 &mut chunks,
887 json!({"content": {"type": "text", "text": "b"}, "meta": {"source": "two"}}),
888 );
889 push_content_chunk(
890 &mut chunks,
891 json!({"content": {"type": "text", "text": "c"}, "meta": {"source": "two"}}),
892 );
893 assert_eq!(
894 chunks,
895 vec![
896 json!({"content": {"type": "text", "text": "a"}, "meta": {"source": "one"}}),
897 json!({"content": {"type": "text", "text": "bc"}, "meta": {"source": "two"}}),
898 ]
899 );
900 }
901
902 #[test]
903 fn push_content_chunk_keeps_chunks_with_differing_annotations_apart() {
904 let mut chunks = vec![
905 json!({"content": {"type": "text", "text": "a", "annotations": {"audience": ["user"]}}}),
906 ];
907 push_content_chunk(
908 &mut chunks,
909 json!({"content": {"type": "text", "text": "b"}}),
910 );
911 assert_eq!(
912 chunks,
913 vec![
914 json!({"content": {"type": "text", "text": "a", "annotations": {"audience": ["user"]}}}),
915 json!({"content": {"type": "text", "text": "b"}}),
916 ]
917 );
918 }
919
920 #[test]
921 fn coalesce_content_chunks_collapses_runs_and_keeps_segment_boundaries() {
922 let mut chunks = vec![
923 text_chunk("He", Some("m1")),
924 text_chunk("llo", Some("m1")),
925 text_chunk("!", Some("m1")),
926 text_chunk("next", Some("m2")),
927 text_chunk(" turn", Some("m2")),
928 ];
929 coalesce_content_chunks(&mut chunks);
930 assert_eq!(
931 chunks,
932 vec![
933 text_chunk("Hello!", Some("m1")),
934 text_chunk("next turn", Some("m2")),
935 ]
936 );
937 }
938
939 #[test]
940 fn coalesce_content_chunks_leaves_unmergeable_chunks_alone() {
941 let original = vec![
942 text_chunk("a", Some("m1")),
943 text_chunk("b", Some("m2")),
944 json!({"content": {"type": "image", "data": "x", "mimeType": "image/png"}}),
945 ];
946 let mut chunks = original.clone();
947 coalesce_content_chunks(&mut chunks);
948 assert_eq!(chunks, original);
949 }
950}