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}