Skip to main content

lore_cli/capture/watchers/
amp.rs

1//! Amp CLI session parser.
2//!
3//! Parses session files from Sourcegraph's Amp CLI tool. Sessions are stored as
4//! single JSON files at `~/.local/share/amp/threads/T-*.json`.
5//!
6//! Each file contains a JSON object with:
7//! - `id`: Thread identifier with "T-" prefix followed by UUID
8//! - `created`: Milliseconds since epoch
9//! - `title`: Optional session title
10//! - `messages`: Array of message objects with role, content, and metadata
11//! - `env.initial.trees`: Array of project trees with working directory info
12
13use anyhow::{Context, Result};
14use chrono::{DateTime, TimeZone, Utc};
15use serde::Deserialize;
16use std::fs;
17use std::path::{Path, PathBuf};
18use uuid::Uuid;
19
20use crate::storage::models::{ContentBlock, Message, MessageContent, MessageRole, Session};
21
22use super::{Watcher, WatcherInfo};
23
24/// Watcher for Amp CLI sessions.
25///
26/// Discovers and parses JSON session files from the Amp CLI tool.
27/// Sessions are stored in `~/.local/share/amp/threads/T-*.json`.
28pub struct AmpWatcher;
29
30impl Watcher for AmpWatcher {
31    fn info(&self) -> WatcherInfo {
32        WatcherInfo {
33            name: "amp",
34            description: "Amp CLI (Sourcegraph)",
35            default_paths: vec![amp_threads_dir()],
36        }
37    }
38
39    fn is_available(&self) -> bool {
40        amp_threads_dir().exists()
41    }
42
43    fn find_sources(&self) -> Result<Vec<PathBuf>> {
44        find_amp_session_files()
45    }
46
47    fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
48        let parsed = parse_amp_session_file(path)?;
49        if parsed.messages.is_empty() {
50            return Ok(vec![]);
51        }
52        let (session, messages) = parsed.to_storage_models();
53        Ok(vec![(session, messages)])
54    }
55
56    fn watch_paths(&self) -> Vec<PathBuf> {
57        vec![amp_threads_dir()]
58    }
59}
60
61/// Returns the path to the Amp threads directory.
62///
63/// Amp uses `~/.local/share/amp/threads/` on all platforms (XDG-style).
64fn amp_threads_dir() -> PathBuf {
65    dirs::home_dir()
66        .unwrap_or_else(|| PathBuf::from("."))
67        .join(".local")
68        .join("share")
69        .join("amp")
70        .join("threads")
71}
72
73/// Raw session structure from Amp JSON files.
74#[derive(Debug, Deserialize)]
75#[serde(rename_all = "camelCase")]
76struct RawAmpSession {
77    id: String,
78    created: i64,
79    #[serde(default)]
80    title: Option<String>,
81    #[serde(default)]
82    messages: Vec<RawAmpMessage>,
83    #[serde(default)]
84    env: Option<RawAmpEnv>,
85}
86
87/// Raw environment structure from Amp JSON files.
88#[derive(Debug, Deserialize)]
89struct RawAmpEnv {
90    #[serde(default)]
91    initial: Option<RawAmpInitialEnv>,
92}
93
94/// Raw initial environment structure containing project trees.
95#[derive(Debug, Deserialize)]
96struct RawAmpInitialEnv {
97    #[serde(default)]
98    trees: Vec<RawAmpTree>,
99}
100
101/// Raw project tree structure from Amp JSON files.
102#[derive(Debug, Deserialize)]
103#[serde(rename_all = "camelCase")]
104struct RawAmpTree {
105    // Parsed for potential future use in display, not currently used
106    #[serde(default)]
107    #[allow(dead_code)]
108    display_name: Option<String>,
109    #[serde(default)]
110    uri: Option<String>,
111    #[serde(default)]
112    repository: Option<RawAmpRepository>,
113}
114
115/// Raw repository structure from Amp JSON files.
116#[derive(Debug, Deserialize)]
117#[serde(rename_all = "camelCase")]
118struct RawAmpRepository {
119    #[serde(rename = "ref")]
120    #[serde(default)]
121    git_ref: Option<String>,
122}
123
124/// Raw message structure from Amp JSON files.
125#[derive(Debug, Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct RawAmpMessage {
128    role: String,
129    #[serde(default)]
130    message_id: Option<i64>,
131    #[serde(default)]
132    content: Vec<RawAmpContentBlock>,
133    #[serde(default)]
134    meta: Option<RawAmpMessageMeta>,
135    #[serde(default)]
136    usage: Option<RawAmpUsage>,
137}
138
139/// Raw message metadata from Amp JSON files.
140#[derive(Debug, Deserialize)]
141#[serde(rename_all = "camelCase")]
142struct RawAmpMessageMeta {
143    #[serde(default)]
144    sent_at: Option<i64>,
145}
146
147/// Raw usage information from Amp JSON files.
148#[derive(Debug, Deserialize)]
149struct RawAmpUsage {
150    #[serde(default)]
151    model: Option<String>,
152}
153
154/// Raw content block structure from Amp JSON files.
155#[derive(Debug, Deserialize)]
156#[serde(tag = "type", rename_all = "snake_case")]
157enum RawAmpContentBlock {
158    Text {
159        text: String,
160    },
161    Thinking {
162        thinking: String,
163    },
164    #[serde(other)]
165    Unknown,
166}
167
168/// Parses an Amp JSON session file.
169///
170/// Reads the JSON file and extracts session metadata and messages.
171///
172/// # Errors
173///
174/// Returns an error if the file cannot be opened or parsed.
175pub fn parse_amp_session_file(path: &Path) -> Result<ParsedAmpSession> {
176    let content = fs::read_to_string(path).context("Failed to read Amp session file")?;
177    let raw: RawAmpSession =
178        serde_json::from_str(&content).context("Failed to parse Amp session JSON")?;
179
180    // Parse session ID by stripping the "T-" prefix
181    let session_id = raw.id.strip_prefix("T-").unwrap_or(&raw.id).to_string();
182
183    // Convert created timestamp from milliseconds to DateTime
184    let created_at = Utc.timestamp_millis_opt(raw.created).single();
185
186    // Extract working directory from first tree's URI
187    let working_directory = raw
188        .env
189        .as_ref()
190        .and_then(|e| e.initial.as_ref())
191        .and_then(|i| i.trees.first())
192        .and_then(|t| t.uri.as_ref())
193        .and_then(|uri| uri.strip_prefix("file://"))
194        .map(String::from);
195
196    // Extract git branch from first tree's repository ref
197    let git_branch = raw
198        .env
199        .as_ref()
200        .and_then(|e| e.initial.as_ref())
201        .and_then(|i| i.trees.first())
202        .and_then(|t| t.repository.as_ref())
203        .and_then(|r| r.git_ref.as_ref())
204        .and_then(|r| r.strip_prefix("refs/heads/"))
205        .map(String::from);
206
207    // Parse messages
208    let mut model: Option<String> = None;
209    let messages: Vec<ParsedAmpMessage> = raw
210        .messages
211        .iter()
212        .filter_map(|m| {
213            let role = match m.role.as_str() {
214                "user" => MessageRole::User,
215                "assistant" => MessageRole::Assistant,
216                "system" => MessageRole::System,
217                _ => return None,
218            };
219
220            // Extract text content and thinking blocks
221            let mut text_parts: Vec<String> = Vec::new();
222            let mut content_blocks: Vec<ContentBlock> = Vec::new();
223            let mut has_thinking = false;
224
225            for block in &m.content {
226                match block {
227                    RawAmpContentBlock::Text { text } => {
228                        text_parts.push(text.clone());
229                        content_blocks.push(ContentBlock::Text { text: text.clone() });
230                    }
231                    RawAmpContentBlock::Thinking { thinking } => {
232                        has_thinking = true;
233                        content_blocks.push(ContentBlock::Thinking {
234                            thinking: thinking.clone(),
235                        });
236                    }
237                    RawAmpContentBlock::Unknown => {}
238                }
239            }
240
241            // Skip messages with no text content
242            if text_parts.is_empty() && !has_thinking {
243                return None;
244            }
245
246            // Capture model from first assistant message with usage info
247            if model.is_none() && role == MessageRole::Assistant {
248                model = m.usage.as_ref().and_then(|u| u.model.clone());
249            }
250
251            // Determine message content - use blocks if we have thinking, text otherwise
252            let content = if has_thinking || content_blocks.len() > 1 {
253                MessageContent::Blocks(content_blocks)
254            } else {
255                MessageContent::Text(text_parts.join("\n"))
256            };
257
258            // Get timestamp from meta.sentAt (milliseconds) or fall back to session created time
259            let timestamp = m
260                .meta
261                .as_ref()
262                .and_then(|meta| meta.sent_at)
263                .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
264                .or(created_at)
265                .unwrap_or_else(Utc::now);
266
267            Some(ParsedAmpMessage {
268                message_id: m.message_id,
269                timestamp,
270                role,
271                content,
272                model: m.usage.as_ref().and_then(|u| u.model.clone()),
273            })
274        })
275        .collect();
276
277    Ok(ParsedAmpSession {
278        session_id,
279        title: raw.title,
280        created_at,
281        working_directory: working_directory.unwrap_or_else(|| ".".to_string()),
282        git_branch,
283        model,
284        messages,
285        source_path: path.to_string_lossy().to_string(),
286    })
287}
288
289/// Intermediate representation of a parsed Amp session.
290#[derive(Debug)]
291pub struct ParsedAmpSession {
292    pub session_id: String,
293    // Parsed for potential future use in session display, not currently used
294    #[allow(dead_code)]
295    pub title: Option<String>,
296    pub created_at: Option<DateTime<Utc>>,
297    pub working_directory: String,
298    pub git_branch: Option<String>,
299    pub model: Option<String>,
300    pub messages: Vec<ParsedAmpMessage>,
301    pub source_path: String,
302}
303
304impl ParsedAmpSession {
305    /// Converts this parsed session to storage-ready models.
306    pub fn to_storage_models(&self) -> (Session, Vec<Message>) {
307        let session_uuid = Uuid::parse_str(&self.session_id).unwrap_or_else(|_| Uuid::new_v4());
308
309        let started_at = self
310            .created_at
311            .or_else(|| self.messages.first().map(|m| m.timestamp))
312            .unwrap_or_else(Utc::now);
313
314        let ended_at = self.messages.last().map(|m| m.timestamp);
315
316        let session = Session {
317            id: session_uuid,
318            tool: "amp".to_string(),
319            tool_version: None,
320            started_at,
321            ended_at,
322            model: self.model.clone(),
323            working_directory: self.working_directory.clone(),
324            git_branch: self.git_branch.clone(),
325            source_path: Some(self.source_path.clone()),
326            message_count: self.messages.len() as i32,
327            machine_id: crate::storage::get_machine_id(),
328        };
329
330        let messages: Vec<Message> = self
331            .messages
332            .iter()
333            .enumerate()
334            .map(|(idx, m)| {
335                let id = Uuid::new_v4();
336
337                Message {
338                    id,
339                    session_id: session_uuid,
340                    parent_id: None,
341                    index: idx as i32,
342                    timestamp: m.timestamp,
343                    role: m.role.clone(),
344                    content: m.content.clone(),
345                    model: m.model.clone(),
346                    git_branch: None,
347                    cwd: None,
348                }
349            })
350            .collect();
351
352        (session, messages)
353    }
354}
355
356/// Intermediate representation of a parsed Amp message.
357#[derive(Debug)]
358pub struct ParsedAmpMessage {
359    // Parsed for potential future use in message ordering, not currently used
360    #[allow(dead_code)]
361    pub message_id: Option<i64>,
362    pub timestamp: DateTime<Utc>,
363    pub role: MessageRole,
364    pub content: MessageContent,
365    pub model: Option<String>,
366}
367
368/// Discovers all Amp session files.
369///
370/// Scans `~/.local/share/amp/threads/` for `T-*.json` files.
371pub fn find_amp_session_files() -> Result<Vec<PathBuf>> {
372    let threads_dir = amp_threads_dir();
373
374    if !threads_dir.exists() {
375        return Ok(Vec::new());
376    }
377
378    let mut files = Vec::new();
379
380    for entry in fs::read_dir(&threads_dir)? {
381        let entry = entry?;
382        let path = entry.path();
383
384        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
385            if name.starts_with("T-") && name.ends_with(".json") {
386                files.push(path);
387            }
388        }
389    }
390
391    Ok(files)
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use std::io::Write;
398    use tempfile::NamedTempFile;
399
400    /// Creates a temporary JSON file with given content.
401    fn create_temp_session_file(content: &str) -> NamedTempFile {
402        let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
403        file.write_all(content.as_bytes())
404            .expect("Failed to write content");
405        file.flush().expect("Failed to flush");
406        file
407    }
408
409    /// Generate a simple Amp session JSON.
410    fn make_session_json(
411        id: &str,
412        created: i64,
413        title: Option<&str>,
414        messages_json: &str,
415        env_json: Option<&str>,
416    ) -> String {
417        let title_str = title
418            .map(|t| format!(r#""title": "{t}","#))
419            .unwrap_or_default();
420        let env_str = env_json
421            .map(|e| format!(r#""env": {e},"#))
422            .unwrap_or_default();
423        format!(
424            r#"{{
425                "v": 235,
426                "id": "{id}",
427                "created": {created},
428                {title_str}
429                {env_str}
430                "messages": {messages_json}
431            }}"#
432        )
433    }
434
435    fn make_env_json(uri: &str, git_ref: Option<&str>) -> String {
436        let repo_str = git_ref
437            .map(|r| format!(r#", "repository": {{"type": "git", "ref": "{r}"}}"#))
438            .unwrap_or_default();
439        format!(
440            r#"{{
441                "initial": {{
442                    "trees": [{{
443                        "displayName": "project",
444                        "uri": "{uri}"{repo_str}
445                    }}]
446                }}
447            }}"#
448        )
449    }
450
451    // Note: Common watcher trait tests (info, watch_paths, find_sources) are in
452    // src/capture/watchers/test_common.rs to avoid duplication across all watchers.
453    // Only tool-specific parsing tests remain here.
454
455    #[test]
456    fn test_parse_simple_session() {
457        let json = make_session_json(
458            "T-019b4d26-22b6-744d-8d30-d6bf43d6b520",
459            1766525903546,
460            Some("Test Session"),
461            r#"[
462                {
463                    "role": "user",
464                    "messageId": 0,
465                    "content": [{"type": "text", "text": "Hello"}],
466                    "meta": {"sentAt": 1766525916428}
467                },
468                {
469                    "role": "assistant",
470                    "messageId": 1,
471                    "content": [{"type": "text", "text": "Hi there!"}],
472                    "usage": {"model": "claude-opus-4-5-20251101", "inputTokens": 9, "outputTokens": 417}
473                }
474            ]"#,
475            None,
476        );
477
478        let file = create_temp_session_file(&json);
479        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
480
481        assert_eq!(parsed.session_id, "019b4d26-22b6-744d-8d30-d6bf43d6b520");
482        assert_eq!(parsed.title, Some("Test Session".to_string()));
483        assert_eq!(parsed.messages.len(), 2);
484        assert_eq!(parsed.messages[0].role, MessageRole::User);
485        assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
486        assert_eq!(parsed.model, Some("claude-opus-4-5-20251101".to_string()));
487    }
488
489    #[test]
490    fn test_parse_session_id_strips_prefix() {
491        let json = make_session_json(
492            "T-550e8400-e29b-41d4-a716-446655440000",
493            1766525903546,
494            None,
495            r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
496            None,
497        );
498
499        let file = create_temp_session_file(&json);
500        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
501
502        assert_eq!(parsed.session_id, "550e8400-e29b-41d4-a716-446655440000");
503    }
504
505    #[test]
506    fn test_parse_user_message() {
507        let json = make_session_json(
508            "T-test-session",
509            1766525903546,
510            None,
511            r#"[{"role": "user", "content": [{"type": "text", "text": "What is Rust?"}]}]"#,
512            None,
513        );
514
515        let file = create_temp_session_file(&json);
516        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
517
518        assert_eq!(parsed.messages.len(), 1);
519        assert_eq!(parsed.messages[0].role, MessageRole::User);
520        assert_eq!(parsed.messages[0].content.text(), "What is Rust?");
521    }
522
523    #[test]
524    fn test_parse_assistant_message_with_model() {
525        let json = make_session_json(
526            "T-test-session",
527            1766525903546,
528            None,
529            r#"[{
530                "role": "assistant",
531                "content": [{"type": "text", "text": "Rust is a systems programming language."}],
532                "usage": {"model": "claude-opus-4-5-20251101"}
533            }]"#,
534            None,
535        );
536
537        let file = create_temp_session_file(&json);
538        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
539
540        assert_eq!(parsed.messages.len(), 1);
541        assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
542        assert_eq!(
543            parsed.messages[0].model,
544            Some("claude-opus-4-5-20251101".to_string())
545        );
546    }
547
548    #[test]
549    fn test_parse_thinking_blocks() {
550        let json = make_session_json(
551            "T-test-session",
552            1766525903546,
553            None,
554            r#"[{
555                "role": "assistant",
556                "content": [
557                    {"type": "thinking", "thinking": "Let me analyze this..."},
558                    {"type": "text", "text": "Here is my answer"}
559                ]
560            }]"#,
561            None,
562        );
563
564        let file = create_temp_session_file(&json);
565        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
566
567        assert_eq!(parsed.messages.len(), 1);
568        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
569            assert_eq!(blocks.len(), 2);
570            assert!(
571                matches!(&blocks[0], ContentBlock::Thinking { thinking } if thinking == "Let me analyze this...")
572            );
573            assert!(
574                matches!(&blocks[1], ContentBlock::Text { text } if text == "Here is my answer")
575            );
576        } else {
577            panic!("Expected Blocks content");
578        }
579    }
580
581    #[test]
582    fn test_parse_working_directory_from_env() {
583        let env = make_env_json("file:///Users/franzer/projects/redactyl", None);
584        let json = make_session_json(
585            "T-test-session",
586            1766525903546,
587            None,
588            r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
589            Some(&env),
590        );
591
592        let file = create_temp_session_file(&json);
593        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
594
595        assert_eq!(parsed.working_directory, "/Users/franzer/projects/redactyl");
596    }
597
598    #[test]
599    fn test_parse_git_branch_from_env() {
600        let env = make_env_json(
601            "file:///Users/franzer/projects/redactyl",
602            Some("refs/heads/main"),
603        );
604        let json = make_session_json(
605            "T-test-session",
606            1766525903546,
607            None,
608            r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
609            Some(&env),
610        );
611
612        let file = create_temp_session_file(&json);
613        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
614
615        assert_eq!(parsed.git_branch, Some("main".to_string()));
616    }
617
618    #[test]
619    fn test_parse_message_timestamp_from_meta() {
620        let json = make_session_json(
621            "T-test-session",
622            1766525903546,
623            None,
624            r#"[{
625                "role": "user",
626                "content": [{"type": "text", "text": "Hello"}],
627                "meta": {"sentAt": 1766525916428}
628            }]"#,
629            None,
630        );
631
632        let file = create_temp_session_file(&json);
633        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
634
635        // The timestamp should be parsed from meta.sentAt
636        assert!(parsed.messages[0].timestamp.timestamp_millis() > 0);
637    }
638
639    #[test]
640    fn test_unknown_message_role_skipped() {
641        let json = make_session_json(
642            "T-test-session",
643            1766525903546,
644            None,
645            r#"[
646                {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
647                {"role": "unknown", "content": [{"type": "text", "text": "Should be skipped"}]},
648                {"role": "assistant", "content": [{"type": "text", "text": "Hi!"}]}
649            ]"#,
650            None,
651        );
652
653        let file = create_temp_session_file(&json);
654        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
655
656        assert_eq!(parsed.messages.len(), 2);
657        assert_eq!(parsed.messages[0].role, MessageRole::User);
658        assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
659    }
660
661    #[test]
662    fn test_empty_content_skipped() {
663        let json = make_session_json(
664            "T-test-session",
665            1766525903546,
666            None,
667            r#"[
668                {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
669                {"role": "assistant", "content": []},
670                {"role": "user", "content": [{"type": "text", "text": "Goodbye"}]}
671            ]"#,
672            None,
673        );
674
675        let file = create_temp_session_file(&json);
676        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
677
678        assert_eq!(parsed.messages.len(), 2);
679    }
680
681    #[test]
682    fn test_to_storage_models() {
683        let env = make_env_json(
684            "file:///Users/franzer/projects/test",
685            Some("refs/heads/feature"),
686        );
687        let json = make_session_json(
688            "T-550e8400-e29b-41d4-a716-446655440000",
689            1766525903546,
690            Some("Test Title"),
691            r#"[
692                {"role": "user", "content": [{"type": "text", "text": "Hello"}]},
693                {"role": "assistant", "content": [{"type": "text", "text": "Hi!"}], "usage": {"model": "claude-opus-4"}}
694            ]"#,
695            Some(&env),
696        );
697
698        let file = create_temp_session_file(&json);
699        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
700        let (session, messages) = parsed.to_storage_models();
701
702        assert_eq!(session.tool, "amp");
703        assert_eq!(
704            session.id.to_string(),
705            "550e8400-e29b-41d4-a716-446655440000"
706        );
707        assert_eq!(session.working_directory, "/Users/franzer/projects/test");
708        assert_eq!(session.git_branch, Some("feature".to_string()));
709        assert_eq!(session.model, Some("claude-opus-4".to_string()));
710        assert_eq!(session.message_count, 2);
711
712        assert_eq!(messages.len(), 2);
713        assert_eq!(messages[0].role, MessageRole::User);
714        assert_eq!(messages[0].index, 0);
715        assert_eq!(messages[1].role, MessageRole::Assistant);
716        assert_eq!(messages[1].index, 1);
717    }
718
719    #[test]
720    fn test_empty_messages_array() {
721        let json = make_session_json("T-test-session", 1766525903546, None, "[]", None);
722
723        let file = create_temp_session_file(&json);
724        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
725
726        assert!(parsed.messages.is_empty());
727    }
728
729    #[test]
730    fn test_watcher_parse_source() {
731        let watcher = AmpWatcher;
732        let json = make_session_json(
733            "T-test-session",
734            1766525903546,
735            None,
736            r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
737            None,
738        );
739
740        let file = create_temp_session_file(&json);
741        let result = watcher
742            .parse_source(file.path())
743            .expect("Should parse successfully");
744
745        assert_eq!(result.len(), 1);
746        let (session, messages) = &result[0];
747        assert_eq!(session.tool, "amp");
748        assert_eq!(messages.len(), 1);
749    }
750
751    #[test]
752    fn test_watcher_parse_source_empty_session() {
753        let watcher = AmpWatcher;
754        let json = make_session_json("T-test-session", 1766525903546, None, "[]", None);
755
756        let file = create_temp_session_file(&json);
757        let result = watcher
758            .parse_source(file.path())
759            .expect("Should parse successfully");
760
761        assert!(result.is_empty());
762    }
763
764    #[test]
765    fn test_invalid_uuid_generates_new() {
766        let json = make_session_json(
767            "T-not-a-valid-uuid",
768            1766525903546,
769            None,
770            r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
771            None,
772        );
773
774        let file = create_temp_session_file(&json);
775        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
776        let (session, _) = parsed.to_storage_models();
777
778        // Should still have a valid UUID (newly generated)
779        assert!(!session.id.is_nil());
780    }
781
782    #[test]
783    fn test_default_working_directory() {
784        let json = make_session_json(
785            "T-test-session",
786            1766525903546,
787            None,
788            r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
789            None,
790        );
791
792        let file = create_temp_session_file(&json);
793        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
794
795        assert_eq!(parsed.working_directory, ".");
796    }
797
798    #[test]
799    fn test_created_timestamp_parsing() {
800        let json = make_session_json(
801            "T-test-session",
802            1766525903546,
803            None,
804            r#"[{"role": "user", "content": [{"type": "text", "text": "Hello"}]}]"#,
805            None,
806        );
807
808        let file = create_temp_session_file(&json);
809        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
810
811        assert!(parsed.created_at.is_some());
812        assert!(parsed.created_at.unwrap().timestamp_millis() > 0);
813    }
814
815    #[test]
816    fn test_system_message() {
817        let json = make_session_json(
818            "T-test-session",
819            1766525903546,
820            None,
821            r#"[{"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]}]"#,
822            None,
823        );
824
825        let file = create_temp_session_file(&json);
826        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
827
828        assert_eq!(parsed.messages.len(), 1);
829        assert_eq!(parsed.messages[0].role, MessageRole::System);
830    }
831
832    #[test]
833    fn test_unknown_content_block_type_skipped() {
834        let json = make_session_json(
835            "T-test-session",
836            1766525903546,
837            None,
838            r#"[{
839                "role": "assistant",
840                "content": [
841                    {"type": "text", "text": "Hello"},
842                    {"type": "tool_use", "id": "123", "name": "Bash"},
843                    {"type": "text", "text": "World"}
844                ]
845            }]"#,
846            None,
847        );
848
849        let file = create_temp_session_file(&json);
850        let parsed = parse_amp_session_file(file.path()).expect("Failed to parse");
851
852        // Should still parse the text blocks
853        assert_eq!(parsed.messages.len(), 1);
854        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
855            // Only text blocks should be present
856            assert_eq!(blocks.len(), 2);
857        } else {
858            panic!("Expected Blocks content");
859        }
860    }
861}