Skip to main content

zeph_commands/
transcript.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared transcript formatting for `/history` (spec-068 §13.6).
5//!
6//! [`TranscriptFormatter`] is the single source of truth for rendering a bounded slice of
7//! conversation history into role-prefixed, tool-collapsed text. Both the flat-text channels
8//! (CLI, Telegram, Discord, Slack) and the TUI backfill path reuse it — neither implements its
9//! own formatting.
10
11/// Role of a [`TranscriptEntry`], decoupled from `zeph_llm::provider::Role` so this crate does
12/// not depend on `zeph-llm` (see the crate-level DRY/dependency note in `lib.rs`).
13#[non_exhaustive]
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TranscriptRole {
16    /// A user turn.
17    User,
18    /// An assistant turn (text reply).
19    Assistant,
20    /// A tool call/result, collapsed to a single line.
21    Tool,
22}
23
24/// One formattable entry in a bounded transcript slice.
25///
26/// Produced by `MessageAccess::transcript_page` — already bounded before this type exists, per
27/// INV-SP-6 (never materialize-then-trim).
28#[derive(Debug, Clone)]
29pub struct TranscriptEntry {
30    /// Speaker role.
31    pub role: TranscriptRole,
32    /// Display text for this entry (already extracted from structured message parts).
33    pub content: String,
34    /// Tool name, set only when `role == Tool`.
35    pub tool_name: Option<String>,
36}
37
38/// Formats bounded [`TranscriptEntry`] slices into role-prefixed, tool-collapsed text.
39pub struct TranscriptFormatter;
40
41impl TranscriptFormatter {
42    /// Render entries as a single newline-joined, role-prefixed string.
43    ///
44    /// Used by every channel that has no structured display buffer of its own (CLI, Telegram,
45    /// Discord, Slack). The TUI backfill path instead pushes each entry individually into its
46    /// own chat message buffer, but still sources entries from the same
47    /// `MessageAccess::transcript_page` bounded slice.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// use zeph_commands::transcript::{TranscriptEntry, TranscriptFormatter, TranscriptRole};
53    ///
54    /// let entries = vec![TranscriptEntry {
55    ///     role: TranscriptRole::User,
56    ///     content: "hello".to_owned(),
57    ///     tool_name: None,
58    /// }];
59    /// let text = TranscriptFormatter::render_flat(&entries);
60    /// assert_eq!(text, "user: hello");
61    /// ```
62    #[must_use]
63    pub fn render_flat(entries: &[TranscriptEntry]) -> String {
64        entries
65            .iter()
66            .map(Self::render_line)
67            .collect::<Vec<_>>()
68            .join("\n")
69    }
70
71    /// Render a single entry as one role-prefixed, tool-collapsed line.
72    #[must_use]
73    pub fn render_line(entry: &TranscriptEntry) -> String {
74        match entry.role {
75            TranscriptRole::User => format!("user: {}", entry.content),
76            TranscriptRole::Assistant => format!("assistant: {}", entry.content),
77            TranscriptRole::Tool => {
78                let name = entry.tool_name.as_deref().unwrap_or("tool");
79                format!("[tool: {name}] {}", entry.content)
80            }
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn render_flat_joins_lines_in_order() {
91        let entries = vec![
92            TranscriptEntry {
93                role: TranscriptRole::User,
94                content: "hi".to_owned(),
95                tool_name: None,
96            },
97            TranscriptEntry {
98                role: TranscriptRole::Assistant,
99                content: "hello".to_owned(),
100                tool_name: None,
101            },
102        ];
103        let text = TranscriptFormatter::render_flat(&entries);
104        assert_eq!(text, "user: hi\nassistant: hello");
105    }
106
107    #[test]
108    fn render_line_collapses_tool_entry() {
109        let entry = TranscriptEntry {
110            role: TranscriptRole::Tool,
111            content: "$ ls\nfile.txt".to_owned(),
112            tool_name: Some("bash".to_owned()),
113        };
114        let line = TranscriptFormatter::render_line(&entry);
115        assert!(line.starts_with("[tool: bash]"));
116    }
117
118    #[test]
119    fn render_line_tool_without_name_falls_back() {
120        let entry = TranscriptEntry {
121            role: TranscriptRole::Tool,
122            content: "output".to_owned(),
123            tool_name: None,
124        };
125        let line = TranscriptFormatter::render_line(&entry);
126        assert!(line.starts_with("[tool: tool]"));
127    }
128
129    #[test]
130    fn render_flat_empty_slice_is_empty_string() {
131        assert_eq!(TranscriptFormatter::render_flat(&[]), "");
132    }
133}