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
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct TerminalOutputRecord {
124 pub terminal_id: String,
125 pub output: String,
126 #[serde(default, skip_serializing_if = "is_false")]
127 pub truncated: bool,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub exit_code: Option<u32>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub signal: Option<String>,
132}
133
134impl TerminalOutputRecord {
135 pub fn exited_cleanly(&self) -> bool {
140 self.exit_code == Some(0) && self.signal.is_none()
141 }
142
143 pub fn matches_tool_raw_result(&self, call: &serde_json::Value) -> bool {
148 if !matches!(
149 call.get("status").and_then(serde_json::Value::as_str),
150 Some("completed" | "failed")
151 ) {
152 return false;
153 }
154 let Some(raw) = call.get("rawOutput") else {
155 return false;
156 };
157 let Some(exit_code) = raw
158 .get("exit_code")
159 .and_then(serde_json::Value::as_u64)
160 .and_then(|code| u32::try_from(code).ok())
161 else {
162 return false;
163 };
164 if self.exit_code != Some(exit_code) || self.signal.is_some() {
165 return false;
166 }
167 match raw.get("output") {
168 Some(serde_json::Value::Array(bytes)) => {
169 bytes.len() == self.output.len()
170 && bytes
171 .iter()
172 .zip(self.output.as_bytes())
173 .all(|(value, byte)| value.as_u64() == Some(u64::from(*byte)))
174 }
175 Some(serde_json::Value::String(output)) => output == &self.output,
176 _ => false,
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct TranscriptItem {
184 pub stable_id: String,
185 pub position: u64,
187 pub latest_content_event_ordinal: Option<u64>,
190 pub created_at_ms: i64,
191 pub last_changed_at_ms: i64,
192 pub body: TranscriptBody,
193}
194
195impl TranscriptItem {
196 pub fn is_session_restart(&self) -> bool {
197 self.stable_id.starts_with(SESSION_RESTART_ITEM_PREFIX)
198 }
199
200 pub fn seq(&self) -> u64 {
208 self.latest_content_event_ordinal.unwrap_or(self.position)
209 }
210
211 pub fn is_turn_start(&self) -> bool {
215 matches!(self.body, TranscriptBody::User { .. })
216 || self.stable_id.starts_with(HARNESS_TURN_ITEM_PREFIX)
217 }
218
219 pub fn is_nonempty_agent_message(&self) -> bool {
220 let TranscriptBody::Agent { chunks, .. } = &self.body else {
221 return false;
222 };
223 chunks.iter().any(|chunk| {
224 let Some(content) = chunk.get("content") else {
225 return false;
226 };
227 match content.get("type").and_then(serde_json::Value::as_str) {
228 Some("text") => content
229 .get("text")
230 .and_then(serde_json::Value::as_str)
231 .is_some_and(|text| !text.trim().is_empty()),
232 Some(_) => true,
233 None => false,
234 }
235 })
236 }
237
238 pub fn validate(&self, through: u64) -> Result<()> {
239 if self.stable_id.trim().is_empty() {
240 bail!("materialized transcript item has an empty stable id");
241 }
242 if self.position == 0 || self.position > through {
243 bail!(
244 "materialized transcript item {:?} has invalid position {} at frontier {through}",
245 self.stable_id,
246 self.position
247 );
248 }
249 match (&self.body, self.latest_content_event_ordinal) {
250 (TranscriptBody::Agent { .. }, Some(ordinal))
251 if ordinal >= self.position && ordinal <= through => {}
252 (TranscriptBody::Agent { .. }, Some(ordinal)) => bail!(
253 "materialized agent message {:?} has invalid latest content ordinal {ordinal} at position {} and frontier {through}",
254 self.stable_id,
255 self.position
256 ),
257 (TranscriptBody::Agent { .. }, None) => bail!(
258 "materialized agent message {:?} has no latest content ordinal",
259 self.stable_id
260 ),
261 (_, Some(ordinal)) => bail!(
262 "non-agent transcript item {:?} has latest content ordinal {ordinal}",
263 self.stable_id
264 ),
265 (_, None) => {}
266 }
267 if self.last_changed_at_ms < self.created_at_ms {
268 bail!(
269 "materialized transcript item {:?} changed before it was created",
270 self.stable_id
271 );
272 }
273 Ok(())
274 }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
278pub enum ChatRole {
279 User,
280 Agent,
281 Thought,
283 Tool,
285 Plan,
287 PlanProposal,
289 System,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
293pub struct ChatEntry {
294 #[serde(default)]
295 pub start_seq: u64,
296 pub seq: u64,
297 pub role: ChatRole,
298 pub text: String,
299 pub recorded_at_ms: Option<i64>,
300 pub revision: u64,
301 pub message_id: Option<String>,
302 pub tool_call_id: Option<String>,
303 pub tool_status: Option<ToolStatus>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub tool_summary: Option<String>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub tool_presentation: Option<ToolCallPresentation>,
312 pub tool_content: Vec<String>,
313 pub tool_diffstats: Vec<String>,
314 pub tool_locations: Vec<String>,
315 pub plan: Vec<PlanLine>,
316 #[serde(default, skip_serializing_if = "is_false")]
317 pub leading_omitted: bool,
318 #[serde(default, skip_serializing_if = "is_false")]
322 pub raw_only: bool,
323 #[serde(skip)]
327 pub source: TranscriptSource,
328}
329
330#[derive(Debug, Clone, Default)]
337pub struct TranscriptSource(pub Option<Arc<TranscriptItem>>);
338
339impl TranscriptSource {
340 pub fn is(&self, item: &Arc<TranscriptItem>) -> bool {
341 self.0
342 .as_ref()
343 .is_some_and(|source| Arc::ptr_eq(source, item))
344 }
345}
346
347impl PartialEq for TranscriptSource {
348 fn eq(&self, _other: &Self) -> bool {
349 true
350 }
351}
352
353impl Eq for TranscriptSource {}
354
355impl ChatEntry {
356 pub fn is_session_restart(&self) -> bool {
361 self.source
362 .0
363 .as_ref()
364 .is_some_and(|item| item.is_session_restart())
365 || (self.role == ChatRole::System && self.text == SESSION_RESTART_TEXT)
366 }
367
368 pub fn plan(seq: u64, plan: Vec<PlanLine>) -> Self {
369 Self {
370 start_seq: seq,
371 seq,
372 role: ChatRole::Plan,
373 text: String::new(),
374 recorded_at_ms: None,
375 revision: 0,
376 message_id: None,
377 tool_call_id: None,
378 tool_status: None,
379 tool_summary: None,
380 tool_presentation: None,
381 tool_content: Vec::new(),
382 tool_diffstats: Vec::new(),
383 tool_locations: Vec::new(),
384 plan,
385 leading_omitted: false,
386 raw_only: false,
387 source: TranscriptSource::default(),
388 }
389 }
390
391 pub fn touch(&mut self, seq: u64) {
392 self.seq = seq;
393 self.revision = self.revision.wrapping_add(1);
394 }
395
396 #[doc(hidden)]
402 pub fn bounded_for_dashboard(mut self) -> Self {
403 self.bound_dashboard_content();
404 self
405 }
406
407 fn bound_dashboard_content(&mut self) {
408 const TEXT_BYTES: usize = 64 * 1024;
409 const DETAIL_BYTES: usize = 2 * 1024;
410 const DETAIL_COUNT: usize = 8;
411
412 self.leading_omitted |= truncate_string_start(&mut self.text, TEXT_BYTES);
413 for values in [
414 &mut self.tool_content,
415 &mut self.tool_diffstats,
416 &mut self.tool_locations,
417 ] {
418 values.truncate(DETAIL_COUNT);
419 for value in values {
420 truncate_string_start(value, DETAIL_BYTES);
421 }
422 }
423 if let Some(summary) = &mut self.tool_summary {
424 truncate_string_start(summary, DETAIL_BYTES);
425 }
426 if let Some(presentation) = &mut self.tool_presentation {
427 truncate_string_start(&mut presentation.summary, DETAIL_BYTES);
428 truncate_string_start(&mut presentation.source, TEXT_BYTES);
429 }
430 self.plan.truncate(DETAIL_COUNT);
431 for line in &mut self.plan {
432 truncate_string_start(&mut line.text, DETAIL_BYTES);
433 }
434 }
435
436 pub fn with_recorded_at(mut self, recorded_at_ms: Option<i64>) -> Self {
437 self.recorded_at_ms = recorded_at_ms;
438 self
439 }
440}
441
442impl ChatEntry {
445 pub fn plain(seq: u64, role: ChatRole, text: impl Into<String>) -> Self {
446 Self {
447 start_seq: seq,
448 seq,
449 role,
450 text: sanitize_terminal_text(&text.into()),
451 recorded_at_ms: None,
452 revision: 0,
453 message_id: None,
454 tool_call_id: None,
455 tool_status: None,
456 tool_summary: None,
457 tool_presentation: None,
458 tool_content: Vec::new(),
459 tool_diffstats: Vec::new(),
460 tool_locations: Vec::new(),
461 plan: Vec::new(),
462 leading_omitted: false,
463 raw_only: false,
464 source: TranscriptSource::default(),
465 }
466 }
467
468 pub fn tool(
469 seq: u64,
470 title: impl Into<String>,
471 tool_call_id: Option<String>,
472 tool_status: ToolStatus,
473 ) -> Self {
474 Self {
475 start_seq: seq,
476 seq,
477 role: ChatRole::Tool,
478 text: sanitize_terminal_text(&title.into()),
479 recorded_at_ms: None,
480 revision: 0,
481 message_id: None,
482 tool_call_id,
483 tool_status: Some(tool_status),
484 tool_summary: None,
485 tool_presentation: None,
486 tool_content: Vec::new(),
487 tool_diffstats: Vec::new(),
488 tool_locations: Vec::new(),
489 plan: Vec::new(),
490 leading_omitted: false,
491 raw_only: false,
492 source: TranscriptSource::default(),
493 }
494 }
495}
496
497pub(crate) fn is_false(value: &bool) -> bool {
498 !*value
499}
500
501pub fn plan_status(status: &PlanEntryStatus) -> PlanStatus {
502 match status {
503 PlanEntryStatus::InProgress => PlanStatus::Running,
504 PlanEntryStatus::Completed => PlanStatus::Completed,
505 _ => PlanStatus::Pending,
506 }
507}
508
509pub fn sanitize_terminal_text(text: &str) -> String {
511 let mut sanitized = String::with_capacity(text.len());
512 let mut chars = text.chars().peekable();
513 while let Some(ch) = chars.next() {
514 if ch == '\x1b' {
515 while consume_escape_body(&mut chars) {}
519 } else if ch == '\r' {
520 if chars.peek() != Some(&'\n') {
521 sanitized.push('\n');
522 }
523 } else if matches!(ch, '\n' | '\t') || !ch.is_control() {
524 sanitized.push(ch);
525 }
526 }
527 sanitized
528}
529
530fn consume_escape_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
537 match chars.next() {
538 Some('[') => {
540 let _ = chars.find(|ch| ('@'..='~').contains(ch));
541 false
542 }
543 Some(']' | 'P' | 'X' | '^' | '_') => consume_string_body(chars),
545 Some('(' | ')' | '*' | '+' | '-' | '.' | '/' | '#' | '%' | ' ') => {
547 chars.next();
548 false
549 }
550 _ => false,
553 }
554}
555
556fn consume_string_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
560 while let Some(&ch) = chars.peek() {
561 match ch {
562 '\n' | '\r' | '\x18' | '\x1a' => return false,
563 '\x07' => {
564 chars.next();
565 return false;
566 }
567 '\x1b' => {
568 chars.next();
569 return true;
570 }
571 _ => {
572 chars.next();
573 }
574 }
575 }
576 false
577}
578
579pub fn materialized_content_text(content: &[serde_json::Value]) -> String {
580 let text = content
581 .iter()
582 .map(materialized_value_text)
583 .filter(|text| !text.is_empty())
584 .collect::<Vec<_>>()
585 .join("\n");
586 crate::relay::strip_hidden_prompt_context(&text).to_owned()
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
595#[serde(rename_all = "snake_case")]
596pub enum TranscriptRole {
597 User,
598 Agent,
599 Thought,
600 Tool,
601 Terminal,
602 Plan,
603 PlanProposal,
604 System,
605}
606
607impl TranscriptRole {
608 pub fn as_str(self) -> &'static str {
609 match self {
610 Self::User => "user",
611 Self::Agent => "agent",
612 Self::Thought => "thought",
613 Self::Tool => "tool",
614 Self::Terminal => "terminal",
615 Self::Plan => "plan",
616 Self::PlanProposal => "plan_proposal",
617 Self::System => "system",
618 }
619 }
620 pub fn storage_kind(self) -> &'static str {
621 match self {
622 Self::Terminal => "terminal_output",
623 other => other.as_str(),
624 }
625 }
626}
627
628pub fn transcript_item_role(body: &TranscriptBody) -> &'static str {
629 let role = match body {
630 TranscriptBody::User { .. } => TranscriptRole::User,
631 TranscriptBody::Agent { .. } => TranscriptRole::Agent,
632 TranscriptBody::Thought { .. } => TranscriptRole::Thought,
633 TranscriptBody::Tool { .. } => TranscriptRole::Tool,
634 TranscriptBody::TerminalOutput { .. } => TranscriptRole::Terminal,
635 TranscriptBody::Plan { .. } => TranscriptRole::Plan,
636 TranscriptBody::PlanProposal { .. } => TranscriptRole::PlanProposal,
637 TranscriptBody::System { .. } => TranscriptRole::System,
638 };
639 role.as_str()
640}
641
642pub fn materialized_chunks_text(chunks: &[serde_json::Value]) -> String {
643 chunks
644 .iter()
645 .filter_map(|value| match ContentChunk::deserialize(value) {
646 Ok(chunk) => Some(chunk),
647 Err(error) => {
648 tracing::warn!(%error, "could not decode a stored content chunk");
649 None
650 }
651 })
652 .filter_map(|chunk| content_block_text(&chunk.content))
653 .map(|text| sanitize_terminal_text(&text))
654 .collect::<Vec<_>>()
655 .join("")
656}
657
658fn materialized_value_text(value: &serde_json::Value) -> String {
659 if let Ok(block) = ContentBlock::deserialize(value)
660 && let Some(text) = content_block_text(&block)
661 {
662 return sanitize_terminal_text(&text);
663 }
664 if let Some(text) = value.as_str() {
665 return sanitize_terminal_text(text);
666 }
667 sanitize_terminal_text(&serde_json::to_string(value).unwrap_or_else(|_| "[content]".into()))
668}
669
670pub fn tool_status(status: &ToolCallStatus) -> ToolStatus {
671 match status {
672 ToolCallStatus::InProgress => ToolStatus::Running,
673 ToolCallStatus::Completed => ToolStatus::Completed,
674 ToolCallStatus::Failed => ToolStatus::Failed,
675 _ => ToolStatus::Pending,
676 }
677}
678
679pub fn content_block_text(content: &ContentBlock) -> Option<String> {
680 match content {
681 ContentBlock::Text(text) => Some(text.text.clone()),
682 ContentBlock::Image(_) => Some("[image]".into()),
683 ContentBlock::Audio(_) => Some("[audio]".into()),
684 ContentBlock::ResourceLink(link) => Some(format!("[{}]({})", link.name, link.uri)),
685 ContentBlock::Resource(resource) => Some(match &resource.resource {
686 EmbeddedResourceResource::TextResourceContents(resource) => resource.text.clone(),
687 EmbeddedResourceResource::BlobResourceContents(resource) => {
688 format!("[embedded resource: {}]", resource.uri)
689 }
690 _ => "[embedded resource]".into(),
691 }),
692 _ => None,
693 }
694}
695
696fn truncate_string_start(value: &mut String, maximum_bytes: usize) -> bool {
697 if value.len() <= maximum_bytes {
698 return false;
699 }
700 let mut start = value.len() - maximum_bytes;
701 while !value.is_char_boundary(start) {
702 start += 1;
703 }
704 value.drain(..start);
705 true
706}
707
708#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
710pub enum ToolStatus {
711 Pending,
712 Running,
713 Completed,
714 Failed,
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
718pub enum PlanStatus {
719 Pending,
720 Running,
721 Completed,
722}
723
724#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
725pub struct PlanLine {
726 pub text: String,
727 pub status: PlanStatus,
728}