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/// What one client-run terminal produced, as hel recorded it when the child
120/// was reaped. `exit_code` and `signal` mirror ACP `TerminalExitStatus`; both
121/// are `None` when the terminal was released before a status was observed.
122#[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    /// Whether the command ended the way a caller asked for: exit status zero
136    /// and no signal. Anything else — a nonzero exit, a signal, or no status at
137    /// all because the terminal was released before one was observed — is
138    /// abnormal, and stays visible in every render mode.
139    pub fn exited_cleanly(&self) -> bool {
140        self.exit_code == Some(0) && self.signal.is_none()
141    }
142
143    /// Whether a completed ACP tool's provider-specific raw result is this
144    /// child result. Kimi reports shell output as a byte array beside its exit
145    /// status but omits the ACP terminal reference, so the exact result is the
146    /// only ownership information it publishes.
147    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    /// Ordinal of the relay event that first created this logical item.
186    pub position: u64,
187    /// Ordinal of the most recent content chunk for an agent message. This is
188    /// `None` for every other logical item.
189    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    /// The relay ordinal a reader pages by.
201    ///
202    /// An agent message is rewritten as its content streams in, and its
203    /// `latest_content_event_ordinal` is where that stopped, so paging by it
204    /// hands a caller the finished message once instead of the partial one it
205    /// was created with. Every other body is created once, so its position is
206    /// its sequence.
207    pub fn seq(&self) -> u64 {
208        self.latest_content_event_ordinal.unwrap_or(self.position)
209    }
210
211    /// Whether this item begins a turn: a user message, or the marker for a
212    /// turn the harness started on its own. The recovery boundary and the
213    /// scope of a plan update both key on the newest of these.
214    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    /// Agent reasoning stream, rendered dimmed.
282    Thought,
283    /// Tool invocation titles.
284    Tool,
285    /// Current agent plan.
286    Plan,
287    /// A plan proposal awaiting, or already given, a decision.
288    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    /// Compact label used by Rich and browser projections. `text` remains the
305    /// original provider title for Raw mode.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub tool_summary: Option<String>,
308    /// Selected bounded source retained so partial ACP updates can preserve a
309    /// raw-command-derived summary without retaining arbitrary raw JSON.
310    #[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    /// Detail the decluttered feed leaves out: the entry renders only in the
319    /// raw transcript mode. Set once, when the entry is built, because Alt-T
320    /// switches render mode without rebuilding entries.
321    #[serde(default, skip_serializing_if = "is_false")]
322    pub raw_only: bool,
323    /// The materialized transcript item this entry was derived from, when it
324    /// came from the controller's projection. Provenance only, so it is
325    /// neither serialized nor part of the entry's value.
326    #[serde(skip)]
327    pub source: TranscriptSource,
328}
329
330/// Handle on the transcript item an entry was derived from. Unchanged items
331/// keep the same `Arc` from one projection to the next, so a pointer
332/// comparison replaces re-reading the item and re-parsing its JSON.
333///
334/// The handle records where an entry came from, not what it says, so two
335/// entries with equal content are equal whatever they were derived from.
336#[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    /// Whether this entry is the durable marker emitted when a session's
357    /// control plane restarts. The source identity is authoritative for
358    /// materialized entries; the role/text check also covers entries built
359    /// from older worker snapshots that have no materialized source handle.
360    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    /// Bound one entry to the sizes the dashboard's summary tolerates.
397    ///
398    /// Compiled unconditionally and hidden from the documentation because the
399    /// chat crate's tests need it, and a `#[cfg(test)]` item is invisible to
400    /// another crate.
401    #[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
442/// Constructors that sanitize the text they are given, so terminal escape
443/// sequences from a harness never reach a transcript entry.
444impl 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
509/// Remove terminal controls while preserving user-visible whitespace.
510pub 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            // One escape can end at the ESC introducing the next one, so keep
516            // consuming rather than recursing: transcript text is untrusted and
517            // may nest these arbitrarily deep.
518            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
530/// Consume one escape sequence's body, after its introducing ESC. Returns
531/// whether the body ended at another ESC, which introduces the next sequence.
532///
533/// Dropping the ESC alone is not enough: an OSC payload (a build tool setting
534/// the window title) or the second byte of a charset selection would otherwise
535/// reach the transcript as visible text.
536fn consume_escape_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> bool {
537    match chars.next() {
538        // CSI: parameter and intermediate bytes up to a final byte.
539        Some('[') => {
540            let _ = chars.find(|ch| ('@'..='~').contains(ch));
541            false
542        }
543        // OSC, DCS, SOS, PM, and APC all carry a string payload.
544        Some(']' | 'P' | 'X' | '^' | '_') => consume_string_body(chars),
545        // Two-byte sequences: charset selection (ESC ( B), ESC # 8, ESC SP F.
546        Some('(' | ')' | '*' | '+' | '-' | '.' | '/' | '#' | '%' | ' ') => {
547            chars.next();
548            false
549        }
550        // Everything else is a complete one-byte escape: ESC 7, ESC 8, ESC M,
551        // ESC =, and a trailing ESC with nothing after it.
552        _ => false,
553    }
554}
555
556/// Consume a string payload, which ends at BEL or at ST (ESC \). A line break
557/// or a cancel control aborts it instead, so one malformed OSC cannot swallow
558/// the rest of a transcript.
559fn 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/// What produced a transcript item, as a stable wire name.
590///
591/// The chat view has its own role enum shaped around how it renders; this is
592/// the name the HTTP API publishes, so it changes only when the transcript
593/// model does.
594#[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/// The ACP tool states needed to keep a compact tool block visually useful.
709#[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}