Skip to main content

talos_session/
transcript.rs

1//! Transcript export service — format-neutral JSON and Markdown export.
2//!
3//! Reads session entries via the [`SessionStore`] abstraction, so both
4//! `.jsonl` and `.tlog` sessions are supported transparently.
5
6use crate::diagnostic::is_terminal_diagnostic_content;
7use crate::{SessionEntry, SessionError, SessionMetadata, SessionStore};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12/// A flattened transcript entry suitable for export.
13///
14/// Unlike [`SessionEntry`], this omits internal fields (`id`, `parent_id`)
15/// and presents only the conversation-relevant data.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TranscriptEntry {
18    /// The role of this entry: `"user"`, `"assistant"`, or `"system"`.
19    pub role: String,
20
21    /// The content of this entry.
22    pub content: String,
23
24    /// When this entry was created. `None` for entries without a timestamp.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub timestamp: Option<DateTime<Utc>>,
27
28    /// Optional metadata (provider, model, token count, etc.).
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub metadata: Option<SessionMetadata>,
31}
32
33impl From<&SessionEntry> for TranscriptEntry {
34    fn from(entry: &SessionEntry) -> Self {
35        let metadata = if entry.metadata.is_empty() {
36            None
37        } else {
38            Some(entry.metadata.clone())
39        };
40        Self {
41            role: entry.role.clone(),
42            content: entry.content.clone(),
43            timestamp: Some(entry.timestamp),
44            metadata,
45        }
46    }
47}
48
49/// Export session entries as a structured JSON array.
50///
51/// Each element contains `role`, `content`, `timestamp`, and optionally `metadata`.
52pub fn export_json(entries: &[SessionEntry]) -> Result<String, serde_json::Error> {
53    let transcript: Vec<TranscriptEntry> = entries
54        .iter()
55        .filter(|entry| !is_terminal_diagnostic_content(&entry.content))
56        .map(TranscriptEntry::from)
57        .collect();
58    serde_json::to_string_pretty(&transcript)
59}
60
61/// Export session entries as a human-readable Markdown transcript.
62///
63/// Format:
64/// ```text
65/// ## User
66/// [content]
67///
68/// ## Assistant
69/// [content]
70///
71/// ## System
72/// [content]
73/// ```
74pub fn export_markdown(entries: &[SessionEntry]) -> String {
75    export_markdown_filtered(entries, false)
76}
77
78#[allow(dead_code)]
79pub fn export_markdown_with_thinking(entries: &[SessionEntry]) -> String {
80    export_markdown_filtered(entries, true)
81}
82
83fn export_markdown_filtered(entries: &[SessionEntry], include_thinking: bool) -> String {
84    if entries.is_empty() {
85        return String::new();
86    }
87
88    let mut output = String::new();
89    let mut first = true;
90    for entry in entries
91        .iter()
92        .filter(|entry| !is_terminal_diagnostic_content(&entry.content))
93    {
94        if !first {
95            output.push_str("\n\n");
96        }
97        first = false;
98
99        if include_thinking
100            && let Some(ref reasoning) = entry.metadata.reasoning
101            && let Some(text) = talos_core::message::project_displayable_reasoning(reasoning)
102        {
103            output.push_str("## Thinking\n");
104            for line in text.lines() {
105                output.push_str(&format!("| {line}\n"));
106            }
107            output.push_str("\n\n");
108        }
109
110        let role_header = match entry.role.as_str() {
111            "user" => "User",
112            "assistant" => "Assistant",
113            "system" => "System",
114            other => other,
115        };
116        output.push_str(&format!("## {}\n{}", role_header, entry.content));
117    }
118    output.push('\n');
119    output
120}
121
122/// Read a session file via the given store and return transcript entries.
123///
124/// Works with any [`SessionStore`] implementation, so both `.jsonl` and
125/// `.tlog` sessions are supported.
126pub fn read_transcript(
127    store: &dyn SessionStore,
128    file_path: &Path,
129) -> Result<Vec<TranscriptEntry>, SessionError> {
130    let entries = store.read_entries(file_path)?;
131    Ok(entries
132        .iter()
133        .filter(|entry| !is_terminal_diagnostic_content(&entry.content))
134        .map(TranscriptEntry::from)
135        .collect())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::SessionEntry;
142    use crate::store::{CompactTextSessionStore, JsonlSessionStore};
143    use chrono::Utc;
144    use talos_core::message::{AssistantReasoning, ReasoningBlock};
145    use uuid::Uuid;
146
147    fn make_entry(role: &str, content: &str) -> SessionEntry {
148        SessionEntry {
149            id: Uuid::new_v4().to_string(),
150            parent_id: None,
151            timestamp: Utc::now(),
152            role: role.into(),
153            content: content.into(),
154            metadata: SessionMetadata::default(),
155        }
156    }
157
158    fn make_entry_with_metadata(role: &str, content: &str) -> SessionEntry {
159        SessionEntry {
160            id: Uuid::new_v4().to_string(),
161            parent_id: None,
162            timestamp: Utc::now(),
163            role: role.into(),
164            content: content.into(),
165            metadata: SessionMetadata {
166                turn_id: None,
167                provider: Some("anthropic".into()),
168                model: Some("claude-sonnet-4-20250514".into()),
169                token_count: Some(42),
170                working_directory: Some("/tmp/project".into()),
171                reasoning: None,
172                raw_content: None,
173            },
174        }
175    }
176
177    fn make_entry_with_reasoning(content: &str, reasoning_text: &str) -> SessionEntry {
178        let mut entry = make_entry("assistant", content);
179        entry.metadata.reasoning = Some(AssistantReasoning {
180            provider: "fixture-provider".into(),
181            model: "fixture-model".into(),
182            blocks: vec![ReasoningBlock::Plain {
183                text: reasoning_text.into(),
184            }],
185        });
186        entry
187    }
188
189    // --- export_json tests ---
190
191    #[test]
192    fn export_json_produces_valid_json() {
193        let entries = vec![
194            make_entry("user", "Hello"),
195            make_entry("assistant", "Hi there"),
196        ];
197        let json = export_json(&entries).expect("operation should succeed");
198        // Should parse back as a JSON array.
199        let parsed: Vec<serde_json::Value> =
200            serde_json::from_str(&json).expect("operation should succeed");
201        assert_eq!(parsed.len(), 2);
202    }
203
204    #[test]
205    fn export_json_has_correct_fields() {
206        let entries = vec![make_entry("user", "test content")];
207        let json = export_json(&entries).expect("operation should succeed");
208        let parsed: Vec<serde_json::Value> =
209            serde_json::from_str(&json).expect("operation should succeed");
210
211        let entry = &parsed[0];
212        assert_eq!(entry["role"], "user");
213        assert_eq!(entry["content"], "test content");
214        assert!(entry.get("timestamp").is_some());
215    }
216
217    #[test]
218    fn export_json_empty_produces_empty_array() {
219        let entries: Vec<SessionEntry> = Vec::new();
220        let json = export_json(&entries).expect("operation should succeed");
221        assert_eq!(json, "[]");
222    }
223
224    #[test]
225    fn export_json_preserves_metadata() {
226        let entries = vec![make_entry_with_metadata("assistant", "response")];
227        let json = export_json(&entries).expect("operation should succeed");
228        let parsed: Vec<serde_json::Value> =
229            serde_json::from_str(&json).expect("operation should succeed");
230
231        let meta = &parsed[0]["metadata"];
232        assert_eq!(meta["provider"], "anthropic");
233        assert_eq!(meta["model"], "claude-sonnet-4-20250514");
234        assert_eq!(meta["token_count"], 42);
235        assert_eq!(meta["working_directory"], "/tmp/project");
236    }
237
238    #[test]
239    fn export_json_omits_empty_metadata() {
240        let entries = vec![make_entry("user", "no metadata")];
241        let json = export_json(&entries).expect("operation should succeed");
242        let parsed: Vec<serde_json::Value> =
243            serde_json::from_str(&json).expect("operation should succeed");
244
245        // metadata should be absent when empty
246        assert!(parsed[0].get("metadata").is_none());
247    }
248
249    // --- export_markdown tests ---
250
251    #[test]
252    fn export_markdown_produces_role_headers() {
253        let entries = vec![
254            make_entry("user", "Hello"),
255            make_entry("assistant", "Hi there"),
256            make_entry("system", "Event"),
257        ];
258        let md = export_markdown(&entries);
259
260        assert!(md.contains("## User"));
261        assert!(md.contains("## Assistant"));
262        assert!(md.contains("## System"));
263    }
264
265    #[test]
266    fn export_markdown_contains_content() {
267        let entries = vec![make_entry("user", "Hello, world!")];
268        let md = export_markdown(&entries);
269        assert!(md.contains("Hello, world!"));
270    }
271
272    #[test]
273    fn export_markdown_empty_produces_empty_string() {
274        let entries: Vec<SessionEntry> = Vec::new();
275        let md = export_markdown(&entries);
276        assert!(md.is_empty());
277    }
278
279    #[test]
280    fn export_markdown_separates_entries() {
281        let entries = vec![
282            make_entry("user", "first"),
283            make_entry("assistant", "second"),
284        ];
285        let md = export_markdown(&entries);
286        // Second entry should be separated by blank lines
287        assert!(md.contains("\n\n## Assistant"));
288    }
289
290    #[test]
291    fn export_markdown_excludes_reasoning_and_derived_thinking_title_by_default() {
292        let entries = vec![make_entry_with_reasoning(
293            "Visible answer",
294            "**Secret Title**\n\nreasoning about api_key=sk-leak",
295        )];
296
297        let md = export_markdown(&entries);
298
299        assert!(md.contains("Visible answer"));
300        assert!(!md.contains("## Thinking"));
301        assert!(!md.contains("Secret Title"));
302        assert!(!md.contains("reasoning about"));
303        assert!(!md.contains("sk-leak"));
304    }
305
306    #[test]
307    fn export_markdown_with_thinking_keeps_explicit_opt_in_behavior() {
308        let entries = vec![make_entry_with_reasoning(
309            "Visible answer",
310            "**Section Title**\n\nreasoning details",
311        )];
312
313        let md = export_markdown_with_thinking(&entries);
314
315        assert!(md.contains("## Thinking"));
316        assert!(md.contains("**Section Title**"));
317        assert!(md.contains("reasoning details"));
318        assert!(md.contains("Visible answer"));
319    }
320
321    // --- read_transcript tests ---
322
323    #[test]
324    fn read_transcript_works_with_jsonl_store() {
325        let dir = tempfile::tempdir().expect("operation should succeed");
326        let path = dir.path().join("test.jsonl");
327
328        let store = JsonlSessionStore;
329        let entry = make_entry("user", "Hello from JSONL");
330        store
331            .append_entry(&path, &entry)
332            .expect("operation should succeed");
333
334        let transcript = read_transcript(&store, &path).expect("operation should succeed");
335        assert_eq!(transcript.len(), 1);
336        assert_eq!(transcript[0].role, "user");
337        assert_eq!(transcript[0].content, "Hello from JSONL");
338    }
339
340    #[test]
341    fn read_transcript_works_with_tlog_store() {
342        let dir = tempfile::tempdir().expect("operation should succeed");
343        let path = dir.path().join("test.tlog");
344
345        let store = CompactTextSessionStore;
346        let entry = make_entry("assistant", "Hello from TLOG");
347        store
348            .append_entry(&path, &entry)
349            .expect("operation should succeed");
350
351        let transcript = read_transcript(&store, &path).expect("operation should succeed");
352        assert_eq!(transcript.len(), 1);
353        assert_eq!(transcript[0].role, "assistant");
354        assert_eq!(transcript[0].content, "Hello from TLOG");
355    }
356
357    #[test]
358    fn read_transcript_empty_session() {
359        let dir = tempfile::tempdir().expect("operation should succeed");
360        let path = dir.path().join("empty.jsonl");
361
362        let store = JsonlSessionStore;
363        let transcript = read_transcript(&store, &path).expect("operation should succeed");
364        assert!(transcript.is_empty());
365    }
366
367    #[test]
368    fn read_transcript_multiple_entries() {
369        let dir = tempfile::tempdir().expect("operation should succeed");
370        let path = dir.path().join("multi.tlog");
371
372        let store = CompactTextSessionStore;
373        let entries = vec![
374            make_entry("user", "Question"),
375            make_entry("assistant", "Answer"),
376            make_entry("system", "Event"),
377        ];
378        for e in &entries {
379            store
380                .append_entry(&path, e)
381                .expect("operation should succeed");
382        }
383
384        let transcript = read_transcript(&store, &path).expect("operation should succeed");
385        assert_eq!(transcript.len(), 3);
386        assert_eq!(transcript[0].role, "user");
387        assert_eq!(transcript[1].role, "assistant");
388        assert_eq!(transcript[2].role, "system");
389    }
390
391    #[test]
392    fn read_transcript_preserves_metadata() {
393        let dir = tempfile::tempdir().expect("operation should succeed");
394        let path = dir.path().join("meta.jsonl");
395
396        let store = JsonlSessionStore;
397        let entry = make_entry_with_metadata("assistant", "with metadata");
398        store
399            .append_entry(&path, &entry)
400            .expect("operation should succeed");
401
402        let transcript = read_transcript(&store, &path).expect("operation should succeed");
403        assert_eq!(transcript.len(), 1);
404        let meta = transcript[0]
405            .metadata
406            .as_ref()
407            .expect("operation should succeed");
408        assert_eq!(meta.provider, Some("anthropic".into()));
409        assert_eq!(meta.model, Some("claude-sonnet-4-20250514".into()));
410    }
411
412    // --- round-trip tests ---
413
414    #[test]
415    fn export_json_round_trip() {
416        let entries = vec![
417            make_entry("user", "Hello"),
418            make_entry_with_metadata("assistant", "Response"),
419        ];
420        let json = export_json(&entries).expect("operation should succeed");
421        let parsed: Vec<TranscriptEntry> =
422            serde_json::from_str(&json).expect("operation should succeed");
423
424        assert_eq!(parsed.len(), 2);
425        assert_eq!(parsed[0].role, "user");
426        assert_eq!(parsed[0].content, "Hello");
427        assert_eq!(parsed[1].role, "assistant");
428        assert_eq!(parsed[1].content, "Response");
429        assert!(parsed[1].metadata.is_some());
430    }
431
432    #[test]
433    fn export_markdown_round_trip_readability() {
434        let entries = vec![
435            make_entry("user", "What is Rust?"),
436            make_entry("assistant", "Rust is a systems programming language."),
437        ];
438        let md = export_markdown(&entries);
439
440        // Should be human-readable
441        assert!(md.contains("## User"));
442        assert!(md.contains("What is Rust?"));
443        assert!(md.contains("## Assistant"));
444        assert!(md.contains("Rust is a systems programming language."));
445    }
446}