Skip to main content

agm_core/agent/session_parser/
claude.rs

1use std::path::PathBuf;
2
3use chrono::DateTime;
4use chrono::Utc;
5use rootcause::option_ext::OptionExt as _;
6use rootcause::prelude::ResultExt as _;
7use serde::Deserialize;
8use serde::de::IgnoredAny;
9
10use crate::agent::Agent;
11use crate::agent::session::Session;
12
13pub fn parse(content: &str) -> rootcause::Result<Session> {
14    let mut session = None;
15    let mut first_user_message = None;
16
17    for (line_idx, line) in content.lines().enumerate() {
18        let line = serde_json::from_str::<ClaudeSessionLine>(line)
19            .context("failed to parse Claude session json line".to_owned())
20            .attach(format!("line_number={}", line_idx.saturating_add(1)))
21            .attach(format!("line={line}"))?;
22
23        if first_user_message.is_none() {
24            first_user_message = line.first_user_message();
25        }
26
27        if session.is_none() {
28            let Some(meta) = line.into_session_meta() else {
29                continue;
30            };
31            let created_at = meta.timestamp;
32
33            session = Some(Session::new(
34                Agent::Claude,
35                meta.session_id,
36                PathBuf::from(meta.cwd),
37                first_user_message.clone(),
38                created_at,
39            ));
40        }
41
42        if session.is_some() && first_user_message.is_some() {
43            break;
44        }
45    }
46
47    let mut session = session.context("no Claude session record found".to_owned())?;
48    if let Some(first_user_message) = first_user_message {
49        session.name = first_user_message;
50    }
51
52    for line in content.lines().rev() {
53        let line = serde_json::from_str::<ClaudeSessionLine>(line)
54            .context("failed to parse Claude session json line".to_owned())
55            .attach(format!("line={line}"))?;
56        let Some(timestamp) = line.timestamp() else {
57            continue;
58        };
59        session.updated_at = timestamp;
60        return Ok(session);
61    }
62
63    session.updated_at = session.created_at;
64    Ok(session)
65}
66
67#[allow(dead_code)]
68#[derive(Debug, Deserialize)]
69#[serde(tag = "type")]
70enum ClaudeSessionLine {
71    #[serde(rename = "user")]
72    User(ClaudeUserLine),
73    #[serde(rename = "assistant")]
74    #[serde(alias = "progress")]
75    #[serde(alias = "system")]
76    #[serde(alias = "attachment")]
77    Metadata(ClaudeAgentLine),
78    #[serde(rename = "queue-operation")]
79    TimestampOnly(ClaudeTimestampedLine),
80    #[serde(other)]
81    Other,
82}
83
84impl ClaudeSessionLine {
85    fn timestamp(&self) -> Option<DateTime<Utc>> {
86        match self {
87            Self::User(line) => Some(line.timestamp),
88            Self::Metadata(line) => Some(line.timestamp),
89            Self::TimestampOnly(line) => Some(line.timestamp),
90            Self::Other => None,
91        }
92    }
93
94    fn first_user_message(&self) -> Option<String> {
95        match self {
96            Self::User(line) => line.message.content.extract_text(),
97            Self::Metadata(_) | Self::TimestampOnly(_) | Self::Other => None,
98        }
99    }
100
101    fn into_session_meta(self) -> Option<ClaudeSessionMeta> {
102        match self {
103            Self::User(line) => line.into_session_meta(),
104            Self::Metadata(line) => line.into_session_meta(),
105            Self::TimestampOnly(_) | Self::Other => None,
106        }
107    }
108}
109
110#[derive(Debug, Deserialize)]
111struct ClaudeSessionMeta {
112    #[serde(rename = "sessionId")]
113    session_id: String,
114    cwd: String,
115    timestamp: DateTime<Utc>,
116}
117
118#[derive(Debug, Deserialize)]
119struct ClaudeUserLine {
120    #[serde(rename = "sessionId")]
121    session_id: String,
122    cwd: String,
123    timestamp: DateTime<Utc>,
124    message: ClaudeMessage,
125}
126
127#[derive(Debug, Deserialize)]
128struct ClaudeAgentLine {
129    #[serde(rename = "sessionId")]
130    session_id: String,
131    cwd: String,
132    timestamp: DateTime<Utc>,
133}
134
135impl ClaudeUserLine {
136    fn into_session_meta(self) -> Option<ClaudeSessionMeta> {
137        Some(ClaudeSessionMeta {
138            session_id: self.session_id,
139            cwd: self.cwd,
140            timestamp: self.timestamp,
141        })
142    }
143}
144
145impl ClaudeAgentLine {
146    fn into_session_meta(self) -> Option<ClaudeSessionMeta> {
147        Some(ClaudeSessionMeta {
148            session_id: self.session_id,
149            cwd: self.cwd,
150            timestamp: self.timestamp,
151        })
152    }
153}
154
155#[allow(dead_code)]
156#[derive(Debug, Deserialize)]
157struct ClaudeTimestampedLine {
158    timestamp: DateTime<Utc>,
159}
160
161#[derive(Debug, Deserialize)]
162struct ClaudeMessage {
163    content: ClaudeUserContent,
164}
165
166#[cfg_attr(test, derive(PartialEq))]
167#[derive(Debug, Deserialize)]
168#[serde(untagged)]
169enum ClaudeUserContent {
170    Text(ClaudeUserText),
171    Parts(Vec<ClaudeUserContentPart>),
172}
173
174impl ClaudeUserContent {
175    fn extract_text(&self) -> Option<String> {
176        match self {
177            Self::Text(text) => text.preview(),
178            Self::Parts(items) => items.iter().find_map(|item| {
179                let ClaudeUserContentPart::Text { text } = item else {
180                    return None;
181                };
182                text.preview()
183            }),
184        }
185    }
186}
187
188#[cfg_attr(test, derive(PartialEq))]
189#[derive(Debug, Deserialize)]
190#[serde(tag = "type", rename_all = "snake_case")]
191enum ClaudeUserContentPart {
192    Text {
193        text: ClaudeUserText,
194    },
195    ToolResult {
196        #[serde(rename = "content")]
197        _content: IgnoredAny,
198    },
199    #[serde(other)]
200    Other,
201}
202
203#[derive(Clone, Debug, Eq, PartialEq)]
204enum ClaudeUserText {
205    Plain(String),
206    Cmd(ClaudeCmdInvocation),
207}
208
209impl ClaudeUserText {
210    fn preview(&self) -> Option<String> {
211        match self {
212            Self::Plain(text) => Some(text.clone()),
213            Self::Cmd(command) => command.preview(),
214        }
215    }
216}
217
218impl<'de> Deserialize<'de> for ClaudeUserText {
219    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
220    where
221        D: serde::Deserializer<'de>,
222    {
223        let text = String::deserialize(deserializer)?;
224        Ok(self::ClaudeCmdInvocation::parse(&text)
225            .map(Self::Cmd)
226            .unwrap_or(Self::Plain(text)))
227    }
228}
229
230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
231enum ClaudeCommandTag {
232    Name,
233    Args,
234}
235
236impl ClaudeCommandTag {
237    const fn open(self) -> &'static str {
238        match self {
239            Self::Name => "<command-name>",
240            Self::Args => "<command-args>",
241        }
242    }
243
244    const fn close(self) -> &'static str {
245        match self {
246            Self::Name => "</command-name>",
247            Self::Args => "</command-args>",
248        }
249    }
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
253struct ClaudeCmdInvocation {
254    name: String,
255    args: Option<String>,
256}
257
258impl ClaudeCmdInvocation {
259    fn parse(text: &str) -> Option<Self> {
260        fn extract_tag(text: &str, tag: ClaudeCommandTag) -> Option<&str> {
261            let start = text.find(tag.open())?.saturating_add(tag.open().len());
262            let end = text[start..].find(tag.close())?.saturating_add(start);
263            Some(&text[start..end])
264        }
265
266        let name = extract_tag(text, ClaudeCommandTag::Name)
267            .map(str::trim)
268            .filter(|name| !name.is_empty())?
269            .to_owned();
270        let args = extract_tag(text, ClaudeCommandTag::Args)
271            .map(str::trim)
272            .filter(|args| !args.is_empty())
273            .map(str::to_owned);
274
275        Some(Self { name, args })
276    }
277
278    fn preview(&self) -> Option<String> {
279        let mut preview = self.name.clone();
280        if let Some(command_args) = self.args.as_deref().map(str::trim).filter(|args| !args.is_empty()) {
281            preview.push(' ');
282            preview.push_str(command_args);
283        }
284        Some(preview)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use tempfile::tempdir;
291
292    use super::*;
293
294    #[test]
295    fn test_parse_claude_session_from_jsonl_lines_sets_workspace_and_id() {
296        let tempdir = tempdir().unwrap();
297        let workspace = tempdir.path().join("workspace");
298        std::fs::create_dir_all(&workspace).unwrap();
299
300        let content = concat!(
301            "{\"type\":\"file-history-snapshot\",\"messageId\":\"m1\",\"snapshot\":{},\"isSnapshotUpdate\":false}\n",
302            "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"__CWD__\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
303            "{\"type\":\"last-prompt\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\",\"lastPrompt\":\"hello\"}\n"
304        )
305        .replace("__CWD__", &workspace.display().to_string());
306
307        assert2::assert!(let Ok(session) = parse(&content));
308        pretty_assertions::assert_eq!(session.agent, Agent::Claude);
309        pretty_assertions::assert_eq!(session.workspace, workspace);
310        pretty_assertions::assert_eq!(session.id, "8649a076-3ead-4d5a-9840-3200f0e1aae5");
311        pretty_assertions::assert_eq!(session.name, "workspace");
312    }
313
314    #[test]
315    fn test_parse_claude_session_with_invalid_scanned_line_returns_error() {
316        let content = "{\"type\":\"progress\",\"timestamp\":\"not-a-date\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n";
317
318        assert2::assert!(let Err(err) = parse(content));
319        assert!(err.to_string().contains("failed to parse Claude session json line"));
320    }
321
322    #[test]
323    fn test_parse_claude_session_without_metadata_returns_error() {
324        let content = "{\"type\":\"last-prompt\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\",\"lastPrompt\":\"hello\"}\n";
325        assert2::assert!(let Err(err) = parse(content));
326        assert!(err.to_string().contains("no Claude session record found"));
327    }
328
329    #[test]
330    fn test_parse_claude_session_with_first_user_message_sets_name_and_updated_at() {
331        let content = concat!(
332            "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
333            "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"this is a very long first user message\"},\"timestamp\":\"2026-03-26T16:52:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n"
334        );
335        assert2::assert!(let Ok(session) = parse(content));
336        pretty_assertions::assert_eq!(session.name, "this is a very long first user message");
337        pretty_assertions::assert_eq!(
338            session.updated_at,
339            chrono::DateTime::parse_from_rfc3339("2026-03-26T16:52:02.119Z")
340                .unwrap()
341                .to_utc()
342        );
343    }
344
345    #[test]
346    fn test_parse_claude_session_with_command_wrapper_sets_command_preview() {
347        let content = concat!(
348            "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
349            "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"<command-message>privoly-admin</command-message>\\n<command-name>/privoly-admin</command-name>\\n<command-args>install</command-args>\"},\"timestamp\":\"2026-03-26T16:52:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n"
350        );
351
352        assert2::assert!(let Ok(session) = parse(content));
353        pretty_assertions::assert_eq!(session.name, "/privoly-admin install");
354    }
355
356    #[test]
357    fn test_extract_text_with_tool_result_then_text_returns_first_text_part() {
358        let value = serde_json::from_str::<ClaudeUserContent>(
359            r#"[{"type":"tool_result","content":"ignored"},{"type":"text","text":"later text"}]"#,
360        )
361        .unwrap();
362
363        pretty_assertions::assert_eq!(value.extract_text(), Some("later text".to_owned()));
364    }
365
366    #[test]
367    fn test_deserialize_claude_user_content_part_text_with_command_wrapper_models_command() {
368        let value = serde_json::from_str::<ClaudeUserContent>(
369            r#"[{"type":"text","text":"<command-message>privoly-admin</command-message>\n<command-name>/privoly-admin</command-name>\n<command-args>install</command-args>"}]"#,
370        )
371        .unwrap();
372
373        pretty_assertions::assert_eq!(
374            value,
375            ClaudeUserContent::Parts(vec![ClaudeUserContentPart::Text {
376                text: ClaudeUserText::Cmd(ClaudeCmdInvocation {
377                    name: "/privoly-admin".to_owned(),
378                    args: Some("install".to_owned()),
379                }),
380            }])
381        );
382    }
383
384    #[test]
385    fn test_parse_claude_session_skips_middle_lines_after_top_loop_stops() {
386        let content = concat!(
387            "{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
388            "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":\"first user msg\"},\"timestamp\":\"2026-03-26T16:52:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
389            "{\"type\":\"broken\"\n",
390            "{\"type\":\"system\",\"timestamp\":\"2026-03-26T16:53:02.119Z\",\"cwd\":\"/tmp/workspace\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\"}\n",
391            "{\"type\":\"last-prompt\",\"sessionId\":\"8649a076-3ead-4d5a-9840-3200f0e1aae5\",\"lastPrompt\":\"hello\"}\n"
392        );
393
394        assert2::assert!(let Ok(session) = parse(content));
395        pretty_assertions::assert_eq!(session.name, "first user msg");
396        pretty_assertions::assert_eq!(
397            session.updated_at,
398            chrono::DateTime::parse_from_rfc3339("2026-03-26T16:53:02.119Z")
399                .unwrap()
400                .to_utc()
401        );
402    }
403}