Skip to main content

claude_code_sdk_rust/
session_summary.rs

1use crate::session_store::{SessionKey, SessionStoreEntry, SessionSummaryEntry};
2use crate::sessions::SDKSessionInfo;
3
4const LAST_WINS_FIELDS: &[(&str, &str)] = &[
5    ("customTitle", "custom_title"),
6    ("aiTitle", "ai_title"),
7    ("lastPrompt", "last_prompt"),
8    ("summary", "summary_hint"),
9    ("gitBranch", "git_branch"),
10];
11
12pub fn fold_session_summary(
13    prev: Option<&SessionSummaryEntry>,
14    key: &SessionKey,
15    entries: &[SessionStoreEntry],
16) -> SessionSummaryEntry {
17    let mut summary = prev.cloned().unwrap_or_else(|| SessionSummaryEntry {
18        session_id: key.session_id.clone(),
19        mtime: 0,
20        data: serde_json::Map::new(),
21    });
22
23    for entry in entries {
24        fold_entry(&mut summary.data, entry);
25    }
26
27    summary
28}
29
30pub fn summary_entry_to_sdk_info(
31    entry: &SessionSummaryEntry,
32    project_path: Option<&str>,
33) -> Option<SDKSessionInfo> {
34    let data = &entry.data;
35    if data
36        .get("is_sidechain")
37        .and_then(|value| value.as_bool())
38        .unwrap_or(false)
39    {
40        return None;
41    }
42
43    let first_prompt = if data
44        .get("first_prompt_locked")
45        .and_then(|value| value.as_bool())
46        .unwrap_or(false)
47    {
48        string_data(data, "first_prompt")
49    } else {
50        string_data(data, "command_fallback")
51    };
52    let custom_title = string_data(data, "custom_title").or_else(|| string_data(data, "ai_title"));
53    let summary = custom_title
54        .clone()
55        .or_else(|| string_data(data, "last_prompt"))
56        .or_else(|| string_data(data, "summary_hint"))
57        .or_else(|| first_prompt.clone())?;
58
59    Some(SDKSessionInfo {
60        session_id: entry.session_id.clone(),
61        summary,
62        last_modified: entry.mtime,
63        file_size: None,
64        custom_title,
65        first_prompt,
66        git_branch: string_data(data, "git_branch"),
67        cwd: string_data(data, "cwd").or_else(|| project_path.map(String::from)),
68        tag: string_data(data, "tag"),
69        created_at: data.get("created_at").and_then(|value| value.as_i64()),
70    })
71}
72
73fn string_data(data: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
74    data.get(key)
75        .and_then(|value| value.as_str())
76        .filter(|value| !value.is_empty())
77        .map(String::from)
78}
79
80fn fold_entry(data: &mut serde_json::Map<String, serde_json::Value>, entry: &SessionStoreEntry) {
81    if !data.contains_key("is_sidechain") {
82        data.insert(
83            "is_sidechain".to_string(),
84            serde_json::Value::Bool(
85                entry.get("isSidechain").and_then(|v| v.as_bool()) == Some(true),
86            ),
87        );
88    }
89
90    if !data.contains_key("created_at") {
91        if let Some(timestamp) = entry.get("timestamp").and_then(|v| v.as_str()) {
92            if let Some(ms) = iso_to_epoch_ms(timestamp) {
93                data.insert("created_at".to_string(), serde_json::json!(ms));
94            }
95        }
96    }
97
98    if !data.contains_key("cwd") {
99        if let Some(cwd) = entry
100            .get("cwd")
101            .and_then(|v| v.as_str())
102            .filter(|cwd| !cwd.is_empty())
103        {
104            data.insert(
105                "cwd".to_string(),
106                serde_json::Value::String(cwd.to_string()),
107            );
108        }
109    }
110
111    fold_first_prompt(data, entry);
112
113    for (src, dst) in LAST_WINS_FIELDS {
114        if let Some(value) = entry.get(*src).and_then(|v| v.as_str()) {
115            data.insert(
116                (*dst).to_string(),
117                serde_json::Value::String(value.to_string()),
118            );
119        }
120    }
121
122    if entry.get("type").and_then(|v| v.as_str()) == Some("tag") {
123        match entry
124            .get("tag")
125            .and_then(|v| v.as_str())
126            .filter(|tag| !tag.is_empty())
127        {
128            Some(tag) => {
129                data.insert(
130                    "tag".to_string(),
131                    serde_json::Value::String(tag.to_string()),
132                );
133            }
134            None => {
135                data.remove("tag");
136            }
137        }
138    }
139}
140
141fn iso_to_epoch_ms(timestamp: &str) -> Option<i64> {
142    chrono::DateTime::parse_from_rfc3339(timestamp)
143        .ok()
144        .map(|dt| dt.timestamp_millis())
145}
146
147fn fold_first_prompt(
148    data: &mut serde_json::Map<String, serde_json::Value>,
149    entry: &SessionStoreEntry,
150) {
151    if data
152        .get("first_prompt_locked")
153        .and_then(|v| v.as_bool())
154        .unwrap_or(false)
155    {
156        return;
157    }
158    if entry.get("type").and_then(|v| v.as_str()) != Some("user") {
159        return;
160    }
161    if entry.get("isMeta").and_then(|v| v.as_bool()) == Some(true)
162        || entry.get("isCompactSummary").and_then(|v| v.as_bool()) == Some(true)
163        || carries_tool_result(entry)
164    {
165        return;
166    }
167
168    for raw in entry_text_blocks(entry) {
169        let mut prompt = raw.replace('\n', " ").trim().to_string();
170        if prompt.is_empty() {
171            continue;
172        }
173        if let Some(command) = command_name(&prompt) {
174            data.entry("command_fallback".to_string())
175                .or_insert_with(|| serde_json::Value::String(command));
176            continue;
177        }
178        if should_skip_first_prompt(&prompt) {
179            continue;
180        }
181        if prompt.len() > 200 {
182            prompt.truncate(200);
183            prompt = prompt.trim_end().to_string();
184            prompt.push('\u{2026}');
185        }
186        data.insert(
187            "first_prompt".to_string(),
188            serde_json::Value::String(prompt),
189        );
190        data.insert(
191            "first_prompt_locked".to_string(),
192            serde_json::Value::Bool(true),
193        );
194        return;
195    }
196}
197
198fn entry_text_blocks(entry: &SessionStoreEntry) -> Vec<String> {
199    let Some(message) = entry.get("message").and_then(|v| v.as_object()) else {
200        return Vec::new();
201    };
202    match message.get("content") {
203        Some(serde_json::Value::String(text)) => vec![text.clone()],
204        Some(serde_json::Value::Array(blocks)) => blocks
205            .iter()
206            .filter_map(|block| {
207                let block = block.as_object()?;
208                (block.get("type").and_then(|v| v.as_str()) == Some("text"))
209                    .then(|| block.get("text").and_then(|v| v.as_str()).map(String::from))
210                    .flatten()
211            })
212            .collect(),
213        _ => Vec::new(),
214    }
215}
216
217fn carries_tool_result(entry: &SessionStoreEntry) -> bool {
218    entry
219        .get("message")
220        .and_then(|v| v.as_object())
221        .and_then(|message| message.get("content"))
222        .and_then(|content| content.as_array())
223        .is_some_and(|blocks| {
224            blocks.iter().any(|block| {
225                block
226                    .as_object()
227                    .and_then(|block| block.get("type"))
228                    .and_then(|v| v.as_str())
229                    == Some("tool_result")
230            })
231        })
232}
233
234fn command_name(prompt: &str) -> Option<String> {
235    let start = prompt.find("<command-name>")? + "<command-name>".len();
236    let end = prompt[start..].find("</command-name>")? + start;
237    Some(prompt[start..end].to_string())
238}
239
240fn should_skip_first_prompt(prompt: &str) -> bool {
241    let trimmed = prompt.trim_start();
242    trimmed.starts_with("<local-command-stdout>")
243        || trimmed.starts_with("<session-start-hook>")
244        || trimmed.starts_with("<tick>")
245        || trimmed.starts_with("<goal>")
246        || trimmed.starts_with("[Request interrupted by user")
247        || (trimmed.starts_with("<ide_opened_file>") && trimmed.ends_with("</ide_opened_file>"))
248        || (trimmed.starts_with("<ide_selection>") && trimmed.ends_with("</ide_selection>"))
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn key() -> SessionKey {
256        SessionKey {
257            project_key: "proj".to_string(),
258            session_id: "session".to_string(),
259            subpath: None,
260        }
261    }
262
263    #[test]
264    fn folds_first_prompt_and_last_wins_fields() {
265        let entries = vec![
266            serde_json::json!({
267                "type": "user",
268                "timestamp": "2026-05-08T10:00:00Z",
269                "cwd": "/repo",
270                "message": {"content": "<command-name>init</command-name>"}
271            })
272            .as_object()
273            .unwrap()
274            .clone(),
275            serde_json::json!({
276                "type": "user",
277                "lastPrompt": "latest prompt",
278                "gitBranch": "main",
279                "message": {"content": [{"type": "text", "text": "Real prompt"}]}
280            })
281            .as_object()
282            .unwrap()
283            .clone(),
284        ];
285
286        let summary = fold_session_summary(None, &key(), &entries);
287
288        assert_eq!(summary.session_id, "session");
289        assert_eq!(summary.data["created_at"], 1_778_234_400_000i64);
290        assert_eq!(summary.data["cwd"], "/repo");
291        assert_eq!(summary.data["command_fallback"], "init");
292        assert_eq!(summary.data["first_prompt"], "Real prompt");
293        assert_eq!(summary.data["last_prompt"], "latest prompt");
294        assert_eq!(summary.data["git_branch"], "main");
295    }
296
297    #[test]
298    fn tag_entries_set_and_clear_tag() {
299        let set_tag = serde_json::json!({"type": "tag", "tag": "important"})
300            .as_object()
301            .unwrap()
302            .clone();
303        let clear_tag = serde_json::json!({"type": "tag", "tag": ""})
304            .as_object()
305            .unwrap()
306            .clone();
307
308        let with_tag = fold_session_summary(None, &key(), &[set_tag]);
309        assert_eq!(with_tag.data["tag"], "important");
310
311        let without_tag = fold_session_summary(Some(&with_tag), &key(), &[clear_tag]);
312        assert!(without_tag.data.get("tag").is_none());
313    }
314}