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:";
23pub const WORK_INTERRUPTED_ITEM_PREFIX: &str = "system:work-interrupted:";
24/// Marks where a turn the user cancelled stopped.
25pub const TURN_INTERRUPTED_TEXT: &str = "Interrupted";
26pub const TURN_INTERRUPTED_ITEM_PREFIX: &str = "system:turn-interrupted:";
27/// Marks the point where the harness resumed work with no prompt in flight.
28pub const HARNESS_TURN_TEXT: &str = "Agent continued on its own";
29pub const HARNESS_TURN_ITEM_PREFIX: &str = "harness-turn:";
30/// User replies within an existing turn, rather than prompts that start one.
31pub const ELICITATION_REPLY_ITEM_PREFIX: &str = "elicitation-reply:";
32
33/// Where a tool's compact presentation source came from.
34#[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/// Bounded presentation metadata derived from an ACP tool call.
43#[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    /// Version of the parser rules that produced `summary`. A missing value
50    /// identifies presentation metadata written before parser versioning.
51    #[serde(default)]
52    pub summary_version: u8,
53}
54
55/// The current value of one logical transcript item. ACP structures whose
56/// schemas can grow are kept as JSON values, while logical item identity and
57/// lifecycle remain controller-owned and stable.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(tag = "kind", rename_all = "snake_case")]
60pub enum TranscriptBody {
61    /// A complete message at the worker's durable delivery boundary.
62    Message {
63        event: Box<crate::mailbox::MailboxEvent>,
64    },
65    User {
66        content: Vec<serde_json::Value>,
67    },
68    Agent {
69        /// Complete ACP `ContentChunk` values, including message IDs, content
70        /// metadata, and non-text content blocks.
71        chunks: Vec<serde_json::Value>,
72        streaming: bool,
73    },
74    Thought {
75        /// Complete ACP `ContentChunk` values, including message IDs, content
76        /// metadata, and non-text content blocks.
77        chunks: Vec<serde_json::Value>,
78        streaming: bool,
79    },
80    Tool {
81        /// Complete current ACP `ToolCall`, updated field-for-field as
82        /// `ToolCallUpdate` notifications arrive.
83        call: serde_json::Value,
84        /// Output of the terminals this call's content refers to. It is a
85        /// sibling of `call` rather than part of it because `ToolCall::update`
86        /// replaces `content` wholesale, which would discard anything injected
87        /// into the stored ACP value.
88        #[serde(default, skip_serializing_if = "Vec::is_empty")]
89        terminal_outputs: Vec<TerminalOutputRecord>,
90        /// Every terminal this call has ever referred to. Agents that replace
91        /// `content` wholesale can drop a terminal reference before the
92        /// terminal is reaped, so the current call is not enough to decide
93        /// where a terminal's output belongs.
94        #[serde(default, skip_serializing_if = "Vec::is_empty")]
95        terminal_refs: Vec<String>,
96        /// Cached label data used by Rich and browser projections. Older
97        /// transcript items omit this and derive it from `call` when read.
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        presentation: Option<Box<ToolCallPresentation>>,
100    },
101    /// Terminal output that no tool call refers to yet. It becomes a
102    /// `Tool` item's `terminal_outputs` entry as soon as a call naming the
103    /// terminal arrives, and stays here permanently otherwise so output is
104    /// never dropped.
105    TerminalOutput {
106        record: TerminalOutputRecord,
107    },
108    Plan {
109        /// Complete current ACP `Plan`, including entry priorities and all
110        /// plan- and entry-level metadata.
111        plan: serde_json::Value,
112    },
113    /// A plan the harness asked the user to approve, captured where the
114    /// decision happened so it renders inline and survives restart and export.
115    ///
116    /// It is a record of the proposal, not conversation input: Hel never
117    /// replays it to a model as a user or agent message.
118    PlanProposal {
119        /// Identity of the plan review that carried this proposal.
120        proposal_id: String,
121        /// Exact proposal text the harness sent.
122        plan: String,
123    },
124    System {
125        text: String,
126    },
127}
128
129/// Append one ACP `ContentChunk` value to a transcript item's chunk list,
130/// merging it into the previous chunk when the two are the same text stream.
131///
132/// Agents stream text one token at a time, so a long turn arrives as
133/// thousands of chunks that differ only in `content.text`. Stored separately
134/// they cost far more memory than the text they carry: each chunk is its own
135/// pair of nested `serde_json::Value` maps. Merging them keeps one chunk per
136/// run of text, which is what every reader of `chunks` already reconstructs.
137///
138/// Two chunks merge only when nothing but the text differs: both are objects
139/// whose `content.type` is `"text"` with a string `content.text`, their
140/// `messageId` values are equal (both absent counts as equal), and every
141/// other top-level key (such as `meta`) and every other `content` key (such
142/// as `annotations`) is identical. Anything else is pushed as its own chunk.
143pub 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
165/// Collapse runs of per-token text chunks that were stored before they were
166/// merged on the way in. Rebuilds the list through [`push_content_chunk`], so
167/// it applies exactly the same merge rule and leaves chunk boundaries that
168/// carry real differences (a new message ID, non-text content, differing
169/// metadata) where they are.
170pub 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
182/// Whether `next` carries only more text for the same stream as `last`, so
183/// the two can share one chunk. The single definition of the merge rule used
184/// by both [`push_content_chunk`] and [`coalesce_content_chunks`].
185fn 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    // Every other top-level key, `messageId` and `meta` included, must match.
206    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    // ... as must every other content key, such as `annotations`.
214    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/// What one client-run terminal produced, as hel recorded it when the child
221/// was reaped. `exit_code` and `signal` mirror ACP `TerminalExitStatus`; both
222/// are `None` when the terminal was released before a status was observed.
223#[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    /// Whether the command ended the way a caller asked for: exit status zero
237    /// and no signal. Anything else — a nonzero exit, a signal, or no status at
238    /// all because the terminal was released before one was observed — is
239    /// abnormal, and stays visible in every render mode.
240    pub fn exited_cleanly(&self) -> bool {
241        self.exit_code == Some(0) && self.signal.is_none()
242    }
243
244    /// Whether a completed ACP tool's provider-specific raw result is this
245    /// child result. Kimi reports shell output as a byte array beside its exit
246    /// status but omits the ACP terminal reference, so the exact result is the
247    /// only ownership information it publishes.
248    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    /// Ordinal of the relay event that first created this logical item.
287    pub position: u64,
288    /// Ordinal of the most recent content chunk for an agent message. This is
289    /// `None` for every other logical item.
290    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    /// The relay ordinal a reader pages by.
306    ///
307    /// An agent message is rewritten as its content streams in, and its
308    /// `latest_content_event_ordinal` is where that stopped, so paging by it
309    /// hands a caller the finished message once instead of the partial one it
310    /// was created with. Every other body is created once, so its position is
311    /// its sequence.
312    pub fn seq(&self) -> u64 {
313        self.latest_content_event_ordinal.unwrap_or(self.position)
314    }
315
316    /// A submitted prompt, excluding replies within an existing turn.
317    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    /// Whether this item begins a turn: a user prompt, or the marker for a
323    /// turn the harness started on its own. The recovery boundary and the
324    /// scope of a plan update both key on the newest of these.
325    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    /// Agent reasoning stream, rendered dimmed.
402    Thought,
403    /// Tool invocation titles.
404    Tool,
405    /// Current agent plan.
406    Plan,
407    /// A plan proposal awaiting, or already given, a decision.
408    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    /// Compact label used by Rich and browser projections. `text` remains the
427    /// original provider title for Raw mode.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub tool_summary: Option<String>,
430    /// Selected bounded source retained so partial ACP updates can preserve a
431    /// raw-command-derived summary without retaining arbitrary raw JSON.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub tool_presentation: Option<ToolCallPresentation>,
434    /// ACP arguments, kept separately so result updates cannot erase them.
435    #[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    /// Detail the decluttered feed leaves out: the entry renders only in the
444    /// raw transcript mode. Set once, when the entry is built, because Alt-T
445    /// switches render mode without rebuilding entries.
446    #[serde(default, skip_serializing_if = "is_false")]
447    pub raw_only: bool,
448    /// A completed tool call whose turn was interrupted while it ran. The
449    /// harness kept the command going and it ended later on its own, so its
450    /// completion is not the agent's finished work.
451    #[serde(default, skip_serializing_if = "is_false")]
452    pub ended_after_interrupt: bool,
453    /// The materialized transcript item this entry was derived from, when it
454    /// came from the controller's projection. Provenance only, so it is
455    /// neither serialized nor part of the entry's value.
456    #[serde(skip)]
457    pub source: TranscriptSource,
458}
459
460/// Handle on the transcript item an entry was derived from. Unchanged items
461/// keep the same `Arc` from one projection to the next, so a pointer
462/// comparison replaces re-reading the item and re-parsing its JSON.
463///
464/// The handle records where an entry came from, not what it says, so two
465/// entries with equal content are equal whatever they were derived from.
466#[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    /// Whether this entry is the durable marker emitted when a session's
500    /// control plane restarts. The source identity is authoritative for
501    /// materialized entries; the role/text check also covers entries built
502    /// from older worker snapshots that have no materialized source handle.
503    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    /// Bound one entry to the sizes the dashboard's summary tolerates.
543    ///
544    /// Compiled unconditionally and hidden from the documentation because the
545    /// chat crate's tests need it, and a `#[cfg(test)]` item is invisible to
546    /// another crate.
547    #[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        // Dashboard summaries do not expand tool arguments.
560        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
590/// Constructors that sanitize the text they are given, so terminal escape
591/// sequences from a harness never reach a transcript entry.
592impl 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
663/// Remove terminal controls while preserving user-visible whitespace.
664pub 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            // One escape can end at the ESC introducing the next one, so keep
670            // consuming rather than recursing: transcript text is untrusted and
671            // may nest these arbitrarily deep.
672            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
684/// Consume one escape sequence's body, after its introducing ESC. Returns
685/// whether the body ended at another ESC, which introduces the next sequence.
686///
687/// Dropping the ESC alone is not enough: an OSC payload (a build tool setting
688/// the window title) or the second byte of a charset selection would otherwise
689/// reach the transcript as visible text.
690fn consume_escape_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
691    match chars.next() {
692        // CSI: parameter and intermediate bytes up to a final byte.
693        Some('[') => {
694            let _ = chars.find(|ch| ('@'..='~').contains(ch));
695            false
696        }
697        // OSC, DCS, SOS, PM, and APC all carry a string payload.
698        Some(']' | 'P' | 'X' | '^' | '_') => consume_string_body(chars),
699        // Two-byte sequences: charset selection (ESC ( B), ESC # 8, ESC SP F.
700        Some('(' | ')' | '*' | '+' | '-' | '.' | '/' | '#' | '%' | ' ') => {
701            chars.next();
702            false
703        }
704        // Everything else is a complete one-byte escape: ESC 7, ESC 8, ESC M,
705        // ESC =, and a trailing ESC with nothing after it.
706        _ => false,
707    }
708}
709
710/// Consume a string payload, which ends at BEL or at ST (ESC \). A line break
711/// or a cancel control aborts it instead, so one malformed OSC cannot swallow
712/// the rest of a transcript.
713fn 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/// What produced a transcript item, as a stable wire name.
744///
745/// The chat view has its own role enum shaped around how it renders; this is
746/// the name the HTTP API publishes, so it changes only when the transcript
747/// model does.
748#[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/// The ACP tool states needed to keep a compact tool block visually useful.
870#[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    // Hard-won: ff088d42: A 15,000-chunk thought made daemon RSS grow from gigabytes of stored JSON into many gigabytes.
904    #[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    // Hard-won: ff088d42: Stored 15,000-chunk transcript rows ballooned daemon RSS after restart.
960    #[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}