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 WORK_INTERRUPTED_ITEM_PREFIX: &str = "system:work-interrupted:";
24pub const TURN_INTERRUPTED_TEXT: &str = "Interrupted";
26pub const TURN_INTERRUPTED_ITEM_PREFIX: &str = "system:turn-interrupted:";
27pub const HARNESS_TURN_TEXT: &str = "Agent continued on its own";
29pub const HARNESS_TURN_ITEM_PREFIX: &str = "harness-turn:";
30pub const ELICITATION_REPLY_ITEM_PREFIX: &str = "elicitation-reply:";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ToolSummarySourceKind {
37 RawInput,
38 RawOutput,
39 Title,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct ToolCallPresentation {
45 pub summary: String,
46 pub source: String,
47 pub source_kind: ToolSummarySourceKind,
48 pub tool_kind: ToolKind,
49 #[serde(default)]
52 pub summary_version: u8,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum TranscriptBody {
61 Message {
63 event: Box<crate::mailbox::MailboxEvent>,
64 },
65 User {
66 content: Vec<serde_json::Value>,
67 },
68 Agent {
69 chunks: Vec<serde_json::Value>,
72 streaming: bool,
73 },
74 Thought {
75 chunks: Vec<serde_json::Value>,
78 streaming: bool,
79 },
80 Tool {
81 call: serde_json::Value,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 terminal_outputs: Vec<TerminalOutputRecord>,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 terminal_refs: Vec<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
99 presentation: Option<Box<ToolCallPresentation>>,
100 },
101 TerminalOutput {
106 record: TerminalOutputRecord,
107 },
108 Plan {
109 plan: serde_json::Value,
112 },
113 PlanProposal {
119 proposal_id: String,
121 plan: String,
123 },
124 System {
125 text: String,
126 },
127}
128
129pub fn push_content_chunk(chunks: &mut Vec<serde_json::Value>, chunk: serde_json::Value) {
144 if chunks
145 .last()
146 .is_some_and(|last| text_chunks_mergeable(last, &chunk))
147 {
148 let addition = chunk
149 .get("content")
150 .and_then(|content| content.get("text"))
151 .and_then(serde_json::Value::as_str)
152 .unwrap_or_default()
153 .to_owned();
154 if let Some(serde_json::Value::Object(last)) = chunks.last_mut()
155 && let Some(serde_json::Value::Object(content)) = last.get_mut("content")
156 && let Some(serde_json::Value::String(text)) = content.get_mut("text")
157 {
158 text.push_str(&addition);
159 return;
160 }
161 }
162 chunks.push(chunk);
163}
164
165pub fn coalesce_content_chunks(chunks: &mut Vec<serde_json::Value>) {
171 if chunks.len() < 2 {
172 return;
173 }
174 let mut merged = Vec::with_capacity(chunks.len());
175 for chunk in std::mem::take(chunks) {
176 push_content_chunk(&mut merged, chunk);
177 }
178 merged.shrink_to_fit();
179 *chunks = merged;
180}
181
182fn text_chunks_mergeable(last: &serde_json::Value, next: &serde_json::Value) -> bool {
186 let (serde_json::Value::Object(last), serde_json::Value::Object(next)) = (last, next) else {
187 return false;
188 };
189 let (
190 Some(serde_json::Value::Object(last_content)),
191 Some(serde_json::Value::Object(next_content)),
192 ) = (last.get("content"), next.get("content"))
193 else {
194 return false;
195 };
196 let is_text = |content: &serde_json::Map<String, serde_json::Value>| {
197 content.get("type").and_then(serde_json::Value::as_str) == Some("text")
198 && content
199 .get("text")
200 .is_some_and(serde_json::Value::is_string)
201 };
202 if !is_text(last_content) || !is_text(next_content) {
203 return false;
204 }
205 if last.len() != next.len()
207 || !last
208 .iter()
209 .all(|(key, value)| key == "content" || next.get(key) == Some(value))
210 {
211 return false;
212 }
213 last_content.len() == next_content.len()
215 && last_content
216 .iter()
217 .all(|(key, value)| key == "text" || next_content.get(key) == Some(value))
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct TerminalOutputRecord {
225 pub terminal_id: String,
226 pub output: String,
227 #[serde(default, skip_serializing_if = "is_false")]
228 pub truncated: bool,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub exit_code: Option<u32>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub signal: Option<String>,
233}
234
235impl TerminalOutputRecord {
236 pub fn exited_cleanly(&self) -> bool {
241 self.exit_code == Some(0) && self.signal.is_none()
242 }
243
244 pub fn matches_tool_raw_result(&self, call: &serde_json::Value) -> bool {
249 if !matches!(
250 call.get("status").and_then(serde_json::Value::as_str),
251 Some("completed" | "failed")
252 ) {
253 return false;
254 }
255 let Some(raw) = call.get("rawOutput") else {
256 return false;
257 };
258 let Some(exit_code) = raw
259 .get("exit_code")
260 .and_then(serde_json::Value::as_u64)
261 .and_then(|code| u32::try_from(code).ok())
262 else {
263 return false;
264 };
265 if self.exit_code != Some(exit_code) || self.signal.is_some() {
266 return false;
267 }
268 match raw.get("output") {
269 Some(serde_json::Value::Array(bytes)) => {
270 bytes.len() == self.output.len()
271 && bytes
272 .iter()
273 .zip(self.output.as_bytes())
274 .all(|(value, byte)| value.as_u64() == Some(u64::from(*byte)))
275 }
276 Some(serde_json::Value::String(output)) => output == &self.output,
277 _ => false,
278 }
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct TranscriptItem {
285 pub stable_id: String,
286 pub position: u64,
288 pub latest_content_event_ordinal: Option<u64>,
291 pub created_at_ms: i64,
292 pub last_changed_at_ms: i64,
293 pub body: TranscriptBody,
294}
295
296impl TranscriptItem {
297 pub fn is_work_interruption(&self) -> bool {
298 self.stable_id.starts_with(WORK_INTERRUPTED_ITEM_PREFIX)
299 }
300
301 pub fn is_session_restart(&self) -> bool {
302 self.stable_id.starts_with(SESSION_RESTART_ITEM_PREFIX)
303 }
304
305 pub fn seq(&self) -> u64 {
313 self.latest_content_event_ordinal.unwrap_or(self.position)
314 }
315
316 pub fn is_user_prompt(&self) -> bool {
318 matches!(self.body, TranscriptBody::User { .. })
319 && !self.stable_id.starts_with(ELICITATION_REPLY_ITEM_PREFIX)
320 }
321
322 pub fn is_turn_start(&self) -> bool {
326 self.is_user_prompt() || self.stable_id.starts_with(HARNESS_TURN_ITEM_PREFIX)
327 }
328
329 pub fn is_nonempty_agent_message(&self) -> bool {
330 let TranscriptBody::Agent { chunks, .. } = &self.body else {
331 return false;
332 };
333 chunks.iter().any(|chunk| {
334 let Some(content) = chunk.get("content") else {
335 return false;
336 };
337 match content.get("type").and_then(serde_json::Value::as_str) {
338 Some("text") => content
339 .get("text")
340 .and_then(serde_json::Value::as_str)
341 .is_some_and(|text| !text.trim().is_empty()),
342 Some(_) => true,
343 None => false,
344 }
345 })
346 }
347
348 pub fn validate(&self, through: u64) -> Result<()> {
349 if self.stable_id.trim().is_empty() {
350 bail!("materialized transcript item has an empty stable id");
351 }
352 if self.position == 0 || self.position > through {
353 bail!(
354 "materialized transcript item {:?} has invalid position {} at frontier {through}",
355 self.stable_id,
356 self.position
357 );
358 }
359 match (&self.body, self.latest_content_event_ordinal) {
360 (TranscriptBody::Agent { .. }, Some(ordinal))
361 if ordinal >= self.position && ordinal <= through => {}
362 (TranscriptBody::Agent { .. }, Some(ordinal)) => bail!(
363 "materialized agent message {:?} has invalid latest content ordinal {ordinal} at position {} and frontier {through}",
364 self.stable_id,
365 self.position
366 ),
367 (TranscriptBody::Agent { .. }, None) => bail!(
368 "materialized agent message {:?} has no latest content ordinal",
369 self.stable_id
370 ),
371 (_, Some(ordinal)) => bail!(
372 "non-agent transcript item {:?} has latest content ordinal {ordinal}",
373 self.stable_id
374 ),
375 (_, None) => {}
376 }
377 if let TranscriptBody::Message { event } = &self.body {
378 anyhow::ensure!(
379 !event.key.trim().is_empty()
380 && event.message_text().is_some()
381 && self.stable_id == message_item_id(&event.key),
382 "invalid message transcript entry {:?}",
383 self.stable_id
384 );
385 }
386 if self.last_changed_at_ms < self.created_at_ms {
387 bail!(
388 "materialized transcript item {:?} changed before it was created",
389 self.stable_id
390 );
391 }
392 Ok(())
393 }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
397pub enum ChatRole {
398 User,
399 Agent,
400 Message,
401 Thought,
403 Tool,
405 Plan,
407 PlanProposal,
409 System,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
413pub struct ChatEntry {
414 #[serde(default)]
415 pub start_seq: u64,
416 pub seq: u64,
417 pub role: ChatRole,
418 pub text: String,
419 pub recorded_at_ms: Option<i64>,
420 pub revision: u64,
421 pub message_id: Option<String>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub incoming_message: Option<Box<crate::mailbox::MailboxEvent>>,
424 pub tool_call_id: Option<String>,
425 pub tool_status: Option<ToolStatus>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub tool_summary: Option<String>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub tool_presentation: Option<ToolCallPresentation>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub tool_input: Option<serde_json::Value>,
437 pub tool_content: Vec<String>,
438 pub tool_diffstats: Vec<String>,
439 pub tool_locations: Vec<String>,
440 pub plan: Vec<PlanLine>,
441 #[serde(default, skip_serializing_if = "is_false")]
442 pub leading_omitted: bool,
443 #[serde(default, skip_serializing_if = "is_false")]
447 pub raw_only: bool,
448 #[serde(default, skip_serializing_if = "is_false")]
452 pub ended_after_interrupt: bool,
453 #[serde(skip)]
457 pub source: TranscriptSource,
458}
459
460#[derive(Debug, Clone, Default)]
467pub struct TranscriptSource(pub Option<Arc<TranscriptItem>>);
468
469impl TranscriptSource {
470 pub fn is(&self, item: &Arc<TranscriptItem>) -> bool {
471 self.0
472 .as_ref()
473 .is_some_and(|source| Arc::ptr_eq(source, item))
474 }
475}
476
477impl PartialEq for TranscriptSource {
478 fn eq(&self, _other: &Self) -> bool {
479 true
480 }
481}
482
483impl Eq for TranscriptSource {}
484
485impl ChatEntry {
486 pub fn received_message(seq: u64, event: &crate::mailbox::MailboxEvent) -> Self {
487 let mut entry = Self::plain(
488 seq,
489 ChatRole::Message,
490 event
491 .message_text()
492 .expect("message entry carries a message event"),
493 );
494 entry.message_id = Some(message_item_id(&event.key));
495 entry.incoming_message = Some(Box::new(event.clone()));
496 entry
497 }
498
499 pub fn is_session_restart(&self) -> bool {
504 self.source
505 .0
506 .as_ref()
507 .is_some_and(|item| item.is_session_restart())
508 || (self.role == ChatRole::System && self.text == SESSION_RESTART_TEXT)
509 }
510
511 pub fn plan(seq: u64, plan: Vec<PlanLine>) -> Self {
512 Self {
513 start_seq: seq,
514 seq,
515 role: ChatRole::Plan,
516 text: String::new(),
517 recorded_at_ms: None,
518 revision: 0,
519 message_id: None,
520 incoming_message: None,
521 tool_call_id: None,
522 tool_status: None,
523 tool_summary: None,
524 tool_presentation: None,
525 tool_input: None,
526 tool_content: Vec::new(),
527 tool_diffstats: Vec::new(),
528 tool_locations: Vec::new(),
529 plan,
530 leading_omitted: false,
531 raw_only: false,
532 ended_after_interrupt: false,
533 source: TranscriptSource::default(),
534 }
535 }
536
537 pub fn touch(&mut self, seq: u64) {
538 self.seq = seq;
539 self.revision = self.revision.wrapping_add(1);
540 }
541
542 #[doc(hidden)]
548 pub fn bounded_for_dashboard(mut self) -> Self {
549 self.bound_dashboard_content();
550 self
551 }
552
553 fn bound_dashboard_content(&mut self) {
554 const TEXT_BYTES: usize = 64 * 1024;
555 const DETAIL_BYTES: usize = 2 * 1024;
556 const DETAIL_COUNT: usize = 8;
557
558 self.leading_omitted |= truncate_string_start(&mut self.text, TEXT_BYTES);
559 self.tool_input = None;
561 for values in [
562 &mut self.tool_content,
563 &mut self.tool_diffstats,
564 &mut self.tool_locations,
565 ] {
566 values.truncate(DETAIL_COUNT);
567 for value in values {
568 truncate_string_start(value, DETAIL_BYTES);
569 }
570 }
571 if let Some(summary) = &mut self.tool_summary {
572 truncate_string_start(summary, DETAIL_BYTES);
573 }
574 if let Some(presentation) = &mut self.tool_presentation {
575 truncate_string_start(&mut presentation.summary, DETAIL_BYTES);
576 truncate_string_start(&mut presentation.source, TEXT_BYTES);
577 }
578 self.plan.truncate(DETAIL_COUNT);
579 for line in &mut self.plan {
580 truncate_string_start(&mut line.text, DETAIL_BYTES);
581 }
582 }
583
584 pub fn with_recorded_at(mut self, recorded_at_ms: Option<i64>) -> Self {
585 self.recorded_at_ms = recorded_at_ms;
586 self
587 }
588}
589
590impl ChatEntry {
593 pub fn plain(seq: u64, role: ChatRole, text: impl Into<String>) -> Self {
594 Self {
595 start_seq: seq,
596 seq,
597 role,
598 text: sanitize_terminal_text(&text.into()),
599 recorded_at_ms: None,
600 revision: 0,
601 message_id: None,
602 incoming_message: None,
603 tool_call_id: None,
604 tool_status: None,
605 tool_summary: None,
606 tool_presentation: None,
607 tool_input: None,
608 tool_content: Vec::new(),
609 tool_diffstats: Vec::new(),
610 tool_locations: Vec::new(),
611 plan: Vec::new(),
612 leading_omitted: false,
613 raw_only: false,
614 ended_after_interrupt: false,
615 source: TranscriptSource::default(),
616 }
617 }
618
619 pub fn tool(
620 seq: u64,
621 title: impl Into<String>,
622 tool_call_id: Option<String>,
623 tool_status: ToolStatus,
624 ) -> Self {
625 Self {
626 start_seq: seq,
627 seq,
628 role: ChatRole::Tool,
629 text: sanitize_terminal_text(&title.into()),
630 recorded_at_ms: None,
631 revision: 0,
632 message_id: None,
633 incoming_message: None,
634 tool_call_id,
635 tool_status: Some(tool_status),
636 tool_summary: None,
637 tool_presentation: None,
638 tool_input: None,
639 tool_content: Vec::new(),
640 tool_diffstats: Vec::new(),
641 tool_locations: Vec::new(),
642 plan: Vec::new(),
643 leading_omitted: false,
644 raw_only: false,
645 ended_after_interrupt: false,
646 source: TranscriptSource::default(),
647 }
648 }
649}
650
651pub(crate) fn is_false(value: &bool) -> bool {
652 !*value
653}
654
655pub fn plan_status(status: &PlanEntryStatus) -> PlanStatus {
656 match status {
657 PlanEntryStatus::InProgress => PlanStatus::Running,
658 PlanEntryStatus::Completed => PlanStatus::Completed,
659 _ => PlanStatus::Pending,
660 }
661}
662
663pub fn sanitize_terminal_text(text: &str) -> String {
665 let mut sanitized = String::with_capacity(text.len());
666 let mut chars = text.chars().peekable();
667 while let Some(ch) = chars.next() {
668 if ch == '\x1b' {
669 while consume_escape_body(&mut chars) {}
673 } else if ch == '\r' {
674 if chars.peek() != Some(&'\n') {
675 sanitized.push('\n');
676 }
677 } else if matches!(ch, '\n' | '\t') || !ch.is_control() {
678 sanitized.push(ch);
679 }
680 }
681 sanitized
682}
683
684fn consume_escape_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
691 match chars.next() {
692 Some('[') => {
694 let _ = chars.find(|ch| ('@'..='~').contains(ch));
695 false
696 }
697 Some(']' | 'P' | 'X' | '^' | '_') => consume_string_body(chars),
699 Some('(' | ')' | '*' | '+' | '-' | '.' | '/' | '#' | '%' | ' ') => {
701 chars.next();
702 false
703 }
704 _ => false,
707 }
708}
709
710fn consume_string_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
714 while let Some(&ch) = chars.peek() {
715 match ch {
716 '\n' | '\r' | '\x18' | '\x1a' => return false,
717 '\x07' => {
718 chars.next();
719 return false;
720 }
721 '\x1b' => {
722 chars.next();
723 return true;
724 }
725 _ => {
726 chars.next();
727 }
728 }
729 }
730 false
731}
732
733pub fn materialized_content_text(content: &[serde_json::Value]) -> String {
734 let text = content
735 .iter()
736 .map(materialized_value_text)
737 .filter(|text| !text.is_empty())
738 .collect::<Vec<_>>()
739 .join("\n");
740 crate::relay::strip_hidden_prompt_context(&text).to_owned()
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
749#[serde(rename_all = "snake_case")]
750pub enum TranscriptRole {
751 User,
752 Agent,
753 Message,
754 Thought,
755 Tool,
756 Terminal,
757 Plan,
758 PlanProposal,
759 System,
760}
761
762impl TranscriptRole {
763 pub fn as_str(self) -> &'static str {
764 match self {
765 Self::User => "user",
766 Self::Agent => "agent",
767 Self::Message => "message",
768 Self::Thought => "thought",
769 Self::Tool => "tool",
770 Self::Terminal => "terminal",
771 Self::Plan => "plan",
772 Self::PlanProposal => "plan_proposal",
773 Self::System => "system",
774 }
775 }
776 pub fn storage_kind(self) -> &'static str {
777 match self {
778 Self::Terminal => "terminal_output",
779 other => other.as_str(),
780 }
781 }
782}
783
784pub fn transcript_item_role(body: &TranscriptBody) -> &'static str {
785 let role = match body {
786 TranscriptBody::User { .. } => TranscriptRole::User,
787 TranscriptBody::Agent { .. } => TranscriptRole::Agent,
788 TranscriptBody::Message { .. } => TranscriptRole::Message,
789 TranscriptBody::Thought { .. } => TranscriptRole::Thought,
790 TranscriptBody::Tool { .. } => TranscriptRole::Tool,
791 TranscriptBody::TerminalOutput { .. } => TranscriptRole::Terminal,
792 TranscriptBody::Plan { .. } => TranscriptRole::Plan,
793 TranscriptBody::PlanProposal { .. } => TranscriptRole::PlanProposal,
794 TranscriptBody::System { .. } => TranscriptRole::System,
795 };
796 role.as_str()
797}
798
799pub fn message_item_id(event_key: &str) -> String {
800 format!("message:{event_key}")
801}
802
803pub fn materialized_chunks_text(chunks: &[serde_json::Value]) -> String {
804 chunks
805 .iter()
806 .filter_map(|value| match ContentChunk::deserialize(value) {
807 Ok(chunk) => Some(chunk),
808 Err(error) => {
809 tracing::warn!(%error, "could not decode a stored content chunk");
810 None
811 }
812 })
813 .filter_map(|chunk| content_block_text(&chunk.content))
814 .map(|text| sanitize_terminal_text(&text))
815 .collect::<Vec<_>>()
816 .join("")
817}
818
819fn materialized_value_text(value: &serde_json::Value) -> String {
820 if let Ok(block) = ContentBlock::deserialize(value)
821 && let Some(text) = content_block_text(&block)
822 {
823 return sanitize_terminal_text(&text);
824 }
825 if let Some(text) = value.as_str() {
826 return sanitize_terminal_text(text);
827 }
828 sanitize_terminal_text(&serde_json::to_string(value).unwrap_or_else(|_| "[content]".into()))
829}
830
831pub fn tool_status(status: &ToolCallStatus) -> ToolStatus {
832 match status {
833 ToolCallStatus::InProgress => ToolStatus::Running,
834 ToolCallStatus::Completed => ToolStatus::Completed,
835 ToolCallStatus::Failed => ToolStatus::Failed,
836 _ => ToolStatus::Pending,
837 }
838}
839
840pub fn content_block_text(content: &ContentBlock) -> Option<String> {
841 match content {
842 ContentBlock::Text(text) => Some(text.text.clone()),
843 ContentBlock::Image(_) => Some("[image]".into()),
844 ContentBlock::Audio(_) => Some("[audio]".into()),
845 ContentBlock::ResourceLink(link) => Some(format!("[{}]({})", link.name, link.uri)),
846 ContentBlock::Resource(resource) => Some(match &resource.resource {
847 EmbeddedResourceResource::TextResourceContents(resource) => resource.text.clone(),
848 EmbeddedResourceResource::BlobResourceContents(resource) => {
849 format!("[embedded resource: {}]", resource.uri)
850 }
851 _ => "[embedded resource]".into(),
852 }),
853 _ => None,
854 }
855}
856
857pub fn truncate_string_start(value: &mut String, maximum_bytes: usize) -> bool {
858 if value.len() <= maximum_bytes {
859 return false;
860 }
861 let mut start = value.len() - maximum_bytes;
862 while !value.is_char_boundary(start) {
863 start += 1;
864 }
865 value.drain(..start);
866 true
867}
868
869#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
871pub enum ToolStatus {
872 Pending,
873 Running,
874 Completed,
875 Failed,
876}
877
878#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
879pub enum PlanStatus {
880 Pending,
881 Running,
882 Completed,
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
886pub struct PlanLine {
887 pub text: String,
888 pub status: PlanStatus,
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use serde_json::json;
895
896 fn text_chunk(text: &str, message_id: Option<&str>) -> serde_json::Value {
897 match message_id {
898 Some(id) => json!({"content": {"type": "text", "text": text}, "messageId": id}),
899 None => json!({"content": {"type": "text", "text": text}}),
900 }
901 }
902
903 #[test]
905 fn push_content_chunk_merges_adjacent_text_for_the_same_message_id() {
906 let mut chunks = vec![text_chunk("x", Some("m1"))];
907 for _ in 1..15_001 {
908 push_content_chunk(&mut chunks, text_chunk("x", Some("m1")));
909 }
910 assert_eq!(chunks, vec![text_chunk(&"x".repeat(15_001), Some("m1"))]);
911 }
912
913 #[test]
914 fn push_content_chunk_keeps_chunks_from_different_message_ids_apart() {
915 let image = json!({"content": {"type": "image", "data": "abc", "mimeType": "image/png"}});
916 let mut chunks = vec![text_chunk("first", Some("m1"))];
917 push_content_chunk(&mut chunks, text_chunk(" more", Some("m1")));
918 push_content_chunk(&mut chunks, text_chunk("second", Some("m2")));
919 push_content_chunk(&mut chunks, text_chunk("third", None));
920 push_content_chunk(&mut chunks, text_chunk(" more", None));
921 push_content_chunk(&mut chunks, image.clone());
922 push_content_chunk(&mut chunks, image.clone());
923 push_content_chunk(
924 &mut chunks,
925 json!({"content": {"type": "text", "text": "a"}, "meta": {"source": "one"}}),
926 );
927 push_content_chunk(
928 &mut chunks,
929 json!({"content": {"type": "text", "text": "b"}, "meta": {"source": "two"}}),
930 );
931 push_content_chunk(
932 &mut chunks,
933 json!({"content": {"type": "text", "text": "c"}, "meta": {"source": "two"}}),
934 );
935 push_content_chunk(
936 &mut chunks,
937 json!({"content": {"type": "text", "text": "annotated", "annotations": {"audience": ["user"]}}}),
938 );
939 push_content_chunk(
940 &mut chunks,
941 json!({"content": {"type": "text", "text": "plain"}}),
942 );
943 assert_eq!(
944 chunks,
945 vec![
946 text_chunk("first more", Some("m1")),
947 text_chunk("second", Some("m2")),
948 text_chunk("third more", None),
949 image.clone(),
950 image,
951 json!({"content": {"type": "text", "text": "a"}, "meta": {"source": "one"}}),
952 json!({"content": {"type": "text", "text": "bc"}, "meta": {"source": "two"}}),
953 json!({"content": {"type": "text", "text": "annotated", "annotations": {"audience": ["user"]}}}),
954 json!({"content": {"type": "text", "text": "plain"}}),
955 ]
956 );
957 }
958
959 #[test]
961 fn coalesce_content_chunks_collapses_runs_and_keeps_segment_boundaries() {
962 let image = json!({"content": {"type": "image", "data": "x", "mimeType": "image/png"}});
963 let mut chunks = (0..15_001)
964 .map(|_| text_chunk("x", Some("m1")))
965 .collect::<Vec<_>>();
966 chunks.extend([
967 text_chunk("next", Some("m2")),
968 text_chunk(" turn", Some("m2")),
969 image.clone(),
970 ]);
971 coalesce_content_chunks(&mut chunks);
972 assert_eq!(
973 chunks,
974 vec![
975 text_chunk(&"x".repeat(15_001), Some("m1")),
976 text_chunk("next turn", Some("m2")),
977 image
978 ]
979 );
980 }
981}