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}