agm_core/agent/session_parser/
claude.rs1use 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}