Skip to main content

mj_core/
transcript.rs

1//! Transcript data shared by the worker, controller state, and the chat UI.
2//!
3//! [`ChatEntry`] is what a worker snapshot carries in its transcript tail and
4//! what the chat view renders, and [`TranscriptItem`] is the materialized
5//! form controller state persists, so both live below the modules that use
6//! them rather than inside any one of them.
7//!
8//! The text helpers that read one of those shapes live here for the same
9//! reason: the database, the projection, controller state, the compactor and
10//! the review host all need the plain text of a stored message, and none of
11//! them should have to reach up into the chat view to get it.
12
13use 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:";
23/// Marks the point where the harness resumed work with no prompt in flight.
24pub const HARNESS_TURN_TEXT: &str = "Agent continued on its own";
25pub const HARNESS_TURN_ITEM_PREFIX: &str = "harness-turn:";
26
27/// Where a tool's compact presentation source came from.
28#[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/// Bounded presentation metadata derived from an ACP tool call.
37#[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    /// Version of the parser rules that produced `summary`. A missing value
44    /// identifies presentation metadata written before parser versioning.
45    #[serde(default)]
46    pub summary_version: u8,
47}
48
49/// The current value of one logical transcript item. ACP structures whose
50/// schemas can grow are kept as JSON values, while logical item identity and
51/// lifecycle remain controller-owned and stable.
52#[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        /// Complete ACP `ContentChunk` values, including message IDs, content
60        /// metadata, and non-text content blocks.
61        chunks: Vec<serde_json::Value>,
62        streaming: bool,
63    },
64    Thought {
65        /// Complete ACP `ContentChunk` values, including message IDs, content
66        /// metadata, and non-text content blocks.
67        chunks: Vec<serde_json::Value>,
68        streaming: bool,
69    },
70    Tool {
71        /// Complete current ACP `ToolCall`, updated field-for-field as
72        /// `ToolCallUpdate` notifications arrive.
73        call: serde_json::Value,
74        /// Output of the terminals this call's content refers to. It is a
75        /// sibling of `call` rather than part of it because `ToolCall::update`
76        /// replaces `content` wholesale, which would discard anything injected
77        /// into the stored ACP value.
78        #[serde(default, skip_serializing_if = "Vec::is_empty")]
79        terminal_outputs: Vec<TerminalOutputRecord>,
80        /// Every terminal this call has ever referred to. Agents that replace
81        /// `content` wholesale can drop a terminal reference before the
82        /// terminal is reaped, so the current call is not enough to decide
83        /// where a terminal's output belongs.
84        #[serde(default, skip_serializing_if = "Vec::is_empty")]
85        terminal_refs: Vec<String>,
86        /// Cached label data used by Rich and browser projections. Older
87        /// transcript items omit this and derive it from `call` when read.
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        presentation: Option<Box<ToolCallPresentation>>,
90    },
91    /// Terminal output that no tool call refers to yet. It becomes a
92    /// `Tool` item's `terminal_outputs` entry as soon as a call naming the
93    /// terminal arrives, and stays here permanently otherwise so output is
94    /// never dropped.
95    TerminalOutput {
96        record: TerminalOutputRecord,
97    },
98    Plan {
99        /// Complete current ACP `Plan`, including entry priorities and all
100        /// plan- and entry-level metadata.
101        plan: serde_json::Value,
102    },
103    /// A plan the harness asked the user to approve, captured where the
104    /// decision happened so it renders inline and survives restart and export.
105    ///
106    /// It is a record of the proposal, not conversation input: Hel never
107    /// replays it to a model as a user or agent message.
108    PlanProposal {
109        /// Identity of the plan review that carried this proposal.
110        proposal_id: String,
111        /// Exact proposal text the harness sent.
112        plan: String,
113    },
114    System {
115        text: String,
116    },
117}
118
119/// Append one ACP `ContentChunk` value to a transcript item's chunk list,
120/// merging it into the previous chunk when the two are the same text stream.
121///
122/// Agents stream text one token at a time, so a long turn arrives as
123/// thousands of chunks that differ only in `content.text`. Stored separately
124/// they cost far more memory than the text they carry: each chunk is its own
125/// pair of nested `serde_json::Value` maps. Merging them keeps one chunk per
126/// run of text, which is what every reader of `chunks` already reconstructs.
127///
128/// Two chunks merge only when nothing but the text differs: both are objects
129/// whose `content.type` is `"text"` with a string `content.text`, their
130/// `messageId` values are equal (both absent counts as equal), and every
131/// other top-level key (such as `meta`) and every other `content` key (such
132/// as `annotations`) is identical. Anything else is pushed as its own chunk.
133pub 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
155/// Collapse runs of per-token text chunks that were stored before they were
156/// merged on the way in. Rebuilds the list through [`push_content_chunk`], so
157/// it applies exactly the same merge rule and leaves chunk boundaries that
158/// carry real differences (a new message ID, non-text content, differing
159/// metadata) where they are.
160pub 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
172/// Whether `next` carries only more text for the same stream as `last`, so
173/// the two can share one chunk. The single definition of the merge rule used
174/// by both [`push_content_chunk`] and [`coalesce_content_chunks`].
175fn 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    // Every other top-level key, `messageId` and `meta` included, must match.
196    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    // ... as must every other content key, such as `annotations`.
204    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/// What one client-run terminal produced, as hel recorded it when the child
211/// was reaped. `exit_code` and `signal` mirror ACP `TerminalExitStatus`; both
212/// are `None` when the terminal was released before a status was observed.
213#[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    /// Whether the command ended the way a caller asked for: exit status zero
227    /// and no signal. Anything else — a nonzero exit, a signal, or no status at
228    /// all because the terminal was released before one was observed — is
229    /// abnormal, and stays visible in every render mode.
230    pub fn exited_cleanly(&self) -> bool {
231        self.exit_code == Some(0) && self.signal.is_none()
232    }
233
234    /// Whether a completed ACP tool's provider-specific raw result is this
235    /// child result. Kimi reports shell output as a byte array beside its exit
236    /// status but omits the ACP terminal reference, so the exact result is the
237    /// only ownership information it publishes.
238    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    /// Ordinal of the relay event that first created this logical item.
277    pub position: u64,
278    /// Ordinal of the most recent content chunk for an agent message. This is
279    /// `None` for every other logical item.
280    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    /// The relay ordinal a reader pages by.
292    ///
293    /// An agent message is rewritten as its content streams in, and its
294    /// `latest_content_event_ordinal` is where that stopped, so paging by it
295    /// hands a caller the finished message once instead of the partial one it
296    /// was created with. Every other body is created once, so its position is
297    /// its sequence.
298    pub fn seq(&self) -> u64 {
299        self.latest_content_event_ordinal.unwrap_or(self.position)
300    }
301
302    /// Whether this item begins a turn: a user message, or the marker for a
303    /// turn the harness started on its own. The recovery boundary and the
304    /// scope of a plan update both key on the newest of these.
305    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    /// Agent reasoning stream, rendered dimmed.
373    Thought,
374    /// Tool invocation titles.
375    Tool,
376    /// Current agent plan.
377    Plan,
378    /// A plan proposal awaiting, or already given, a decision.
379    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    /// Compact label used by Rich and browser projections. `text` remains the
396    /// original provider title for Raw mode.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub tool_summary: Option<String>,
399    /// Selected bounded source retained so partial ACP updates can preserve a
400    /// raw-command-derived summary without retaining arbitrary raw JSON.
401    #[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    /// Detail the decluttered feed leaves out: the entry renders only in the
410    /// raw transcript mode. Set once, when the entry is built, because Alt-T
411    /// switches render mode without rebuilding entries.
412    #[serde(default, skip_serializing_if = "is_false")]
413    pub raw_only: bool,
414    /// The materialized transcript item this entry was derived from, when it
415    /// came from the controller's projection. Provenance only, so it is
416    /// neither serialized nor part of the entry's value.
417    #[serde(skip)]
418    pub source: TranscriptSource,
419}
420
421/// Handle on the transcript item an entry was derived from. Unchanged items
422/// keep the same `Arc` from one projection to the next, so a pointer
423/// comparison replaces re-reading the item and re-parsing its JSON.
424///
425/// The handle records where an entry came from, not what it says, so two
426/// entries with equal content are equal whatever they were derived from.
427#[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    /// Whether this entry is the durable marker emitted when a session's
448    /// control plane restarts. The source identity is authoritative for
449    /// materialized entries; the role/text check also covers entries built
450    /// from older worker snapshots that have no materialized source handle.
451    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    /// Bound one entry to the sizes the dashboard's summary tolerates.
488    ///
489    /// Compiled unconditionally and hidden from the documentation because the
490    /// chat crate's tests need it, and a `#[cfg(test)]` item is invisible to
491    /// another crate.
492    #[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
533/// Constructors that sanitize the text they are given, so terminal escape
534/// sequences from a harness never reach a transcript entry.
535impl 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
600/// Remove terminal controls while preserving user-visible whitespace.
601pub 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            // One escape can end at the ESC introducing the next one, so keep
607            // consuming rather than recursing: transcript text is untrusted and
608            // may nest these arbitrarily deep.
609            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
621/// Consume one escape sequence's body, after its introducing ESC. Returns
622/// whether the body ended at another ESC, which introduces the next sequence.
623///
624/// Dropping the ESC alone is not enough: an OSC payload (a build tool setting
625/// the window title) or the second byte of a charset selection would otherwise
626/// reach the transcript as visible text.
627fn consume_escape_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
628    match chars.next() {
629        // CSI: parameter and intermediate bytes up to a final byte.
630        Some('[') => {
631            let _ = chars.find(|ch| ('@'..='~').contains(ch));
632            false
633        }
634        // OSC, DCS, SOS, PM, and APC all carry a string payload.
635        Some(']' | 'P' | 'X' | '^' | '_') => consume_string_body(chars),
636        // Two-byte sequences: charset selection (ESC ( B), ESC # 8, ESC SP F.
637        Some('(' | ')' | '*' | '+' | '-' | '.' | '/' | '#' | '%' | ' ') => {
638            chars.next();
639            false
640        }
641        // Everything else is a complete one-byte escape: ESC 7, ESC 8, ESC M,
642        // ESC =, and a trailing ESC with nothing after it.
643        _ => false,
644    }
645}
646
647/// Consume a string payload, which ends at BEL or at ST (ESC \). A line break
648/// or a cancel control aborts it instead, so one malformed OSC cannot swallow
649/// the rest of a transcript.
650fn 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/// What produced a transcript item, as a stable wire name.
681///
682/// The chat view has its own role enum shaped around how it renders; this is
683/// the name the HTTP API publishes, so it changes only when the transcript
684/// model does.
685#[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/// The ACP tool states needed to keep a compact tool block visually useful.
800#[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}