1use crate::trace::{Event, EventType, Session, Stats};
2use serde::{Deserialize, Serialize};
3
4pub const ATTR_CWD: &str = "cwd";
5pub const ATTR_WORKING_DIRECTORY: &str = "working_directory";
6pub const ATTR_SOURCE_PATH: &str = "source_path";
7pub const ATTR_SESSION_ROLE: &str = "session_role";
8pub const ATTR_PARENT_SESSION_ID: &str = "parent_session_id";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SessionRole {
12 Primary,
13 Auxiliary,
14}
15
16fn attr_non_empty_str<'a>(session: &'a Session, key: &str) -> Option<&'a str> {
17 session
18 .context
19 .attributes
20 .get(key)
21 .and_then(|value| value.as_str())
22 .map(str::trim)
23 .filter(|value| !value.is_empty())
24}
25
26pub fn working_directory(session: &Session) -> Option<&str> {
27 attr_non_empty_str(session, ATTR_CWD)
28 .or_else(|| attr_non_empty_str(session, ATTR_WORKING_DIRECTORY))
29}
30
31pub fn source_path(session: &Session) -> Option<&str> {
32 attr_non_empty_str(session, ATTR_SOURCE_PATH)
33}
34
35pub fn session_role(session: &Session) -> SessionRole {
36 if let Some(raw_role) = attr_non_empty_str(session, ATTR_SESSION_ROLE) {
37 if raw_role.eq_ignore_ascii_case("auxiliary") {
38 return SessionRole::Auxiliary;
39 }
40 if raw_role.eq_ignore_ascii_case("primary") {
41 return SessionRole::Primary;
42 }
43 }
44
45 if !session.context.related_session_ids.is_empty() {
46 return SessionRole::Auxiliary;
47 }
48
49 if attr_non_empty_str(session, ATTR_PARENT_SESSION_ID).is_some() {
50 return SessionRole::Auxiliary;
51 }
52
53 SessionRole::Primary
54}
55
56pub fn is_auxiliary_session(session: &Session) -> bool {
57 session_role(session) == SessionRole::Auxiliary
58}
59
60pub fn interaction_compressed_session(session: &Session) -> Session {
64 let mut compressed = session.clone();
65 compressed.events.retain(is_interaction_flow_event);
66 compressed.recompute_stats();
67 compressed
68}
69
70pub fn interaction_compressed_stats(session: &Session) -> Stats {
75 interaction_compressed_session(session).stats
76}
77
78fn is_interaction_flow_event(event: &Event) -> bool {
79 if is_interrupt_like_event(event) {
80 return true;
81 }
82
83 if matches!(
84 event.event_type,
85 EventType::UserMessage | EventType::AgentMessage
86 ) {
87 return true;
88 }
89
90 if matches!(event.event_type, EventType::SystemMessage)
91 && event
92 .attr_str("source")
93 .is_some_and(|source| source.eq_ignore_ascii_case("interactive_question"))
94 {
95 return true;
96 }
97
98 match &event.event_type {
99 EventType::ToolCall { name } | EventType::ToolResult { name, .. } => {
100 event
101 .semantic_tool_kind()
102 .is_some_and(|kind| kind.eq_ignore_ascii_case("interactive"))
103 || matches!(
104 name.trim().to_ascii_lowercase().as_str(),
105 "request_user_input" | "ask_followup_question"
106 )
107 }
108 _ => false,
109 }
110}
111
112fn is_interrupt_like_event(event: &Event) -> bool {
113 if let EventType::Custom { kind } = &event.event_type {
114 let lowered = kind.trim().to_ascii_lowercase();
115 return lowered == "turn_aborted"
116 || lowered.contains("interrupt")
117 || lowered.contains("aborted");
118 }
119 false
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
123pub struct GitMeta {
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub remote: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub repo_name: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub branch: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub head: Option<String>,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub commits: Vec<String>,
134}
135
136pub fn build_git_storage_meta_json_with_git(session: &Session, git: Option<&GitMeta>) -> Vec<u8> {
137 let role = match session_role(session) {
138 SessionRole::Primary => "primary",
139 SessionRole::Auxiliary => "auxiliary",
140 };
141
142 let mut payload = serde_json::json!({
143 "schema_version": 2,
144 "session_id": session.session_id,
145 "title": session.context.title,
146 "tool": session.agent.tool,
147 "model": session.agent.model,
148 "session_role": role,
149 "stats": session.stats,
150 });
151
152 if let Some(git_meta) = git {
153 let has_git = git_meta.remote.is_some()
154 || git_meta.repo_name.is_some()
155 || git_meta.branch.is_some()
156 || git_meta.head.is_some()
157 || !git_meta.commits.is_empty();
158 if has_git {
159 payload["git"] = serde_json::to_value(git_meta).unwrap_or_default();
160 }
161 }
162
163 serde_json::to_vec_pretty(&payload).unwrap_or_default()
164}
165
166pub fn build_git_storage_meta_json(session: &Session) -> Vec<u8> {
167 build_git_storage_meta_json_with_git(session, None)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::{
173 build_git_storage_meta_json, build_git_storage_meta_json_with_git,
174 interaction_compressed_session, interaction_compressed_stats, is_auxiliary_session,
175 session_role, source_path, working_directory, GitMeta, SessionRole, ATTR_PARENT_SESSION_ID,
176 ATTR_SESSION_ROLE,
177 };
178 use crate::trace::{Agent, Content, Event, EventType, Session};
179 use serde_json::Value;
180 use std::collections::HashMap;
181
182 fn make_session() -> Session {
183 Session::new(
184 "s1".to_string(),
185 Agent {
186 provider: "openai".to_string(),
187 model: "gpt-5".to_string(),
188 tool: "codex".to_string(),
189 tool_version: None,
190 },
191 )
192 }
193
194 #[test]
195 fn working_directory_prefers_cwd() {
196 let mut session = make_session();
197 session.context.attributes.insert(
198 "cwd".to_string(),
199 Value::String("/repo/preferred".to_string()),
200 );
201 session.context.attributes.insert(
202 "working_directory".to_string(),
203 Value::String("/repo/fallback".to_string()),
204 );
205
206 assert_eq!(working_directory(&session), Some("/repo/preferred"));
207 }
208
209 #[test]
210 fn working_directory_uses_working_directory_fallback() {
211 let mut session = make_session();
212 session.context.attributes.insert(
213 "working_directory".to_string(),
214 Value::String("/repo/fallback".to_string()),
215 );
216
217 assert_eq!(working_directory(&session), Some("/repo/fallback"));
218 }
219
220 #[test]
221 fn source_path_returns_non_empty_value() {
222 let mut session = make_session();
223 session.context.attributes.insert(
224 "source_path".to_string(),
225 Value::String("/tmp/session.jsonl".to_string()),
226 );
227
228 assert_eq!(source_path(&session), Some("/tmp/session.jsonl"));
229 }
230
231 #[test]
232 fn session_role_uses_explicit_attribute_first() {
233 let mut session = make_session();
234 session.context.related_session_ids = vec!["parent-id".to_string()];
235 session.context.attributes.insert(
236 ATTR_SESSION_ROLE.to_string(),
237 Value::String("primary".to_string()),
238 );
239
240 assert_eq!(session_role(&session), SessionRole::Primary);
241 assert!(!is_auxiliary_session(&session));
242 }
243
244 #[test]
245 fn session_role_uses_related_session_ids() {
246 let mut session = make_session();
247 session.context.related_session_ids = vec!["parent-id".to_string()];
248
249 assert_eq!(session_role(&session), SessionRole::Auxiliary);
250 assert!(is_auxiliary_session(&session));
251 }
252
253 #[test]
254 fn session_role_uses_parent_session_id_attribute() {
255 let mut session = make_session();
256 session.context.attributes.insert(
257 ATTR_PARENT_SESSION_ID.to_string(),
258 Value::String("parent-id".to_string()),
259 );
260
261 assert_eq!(session_role(&session), SessionRole::Auxiliary);
262 }
263
264 #[test]
265 fn session_role_defaults_to_primary() {
266 let session = make_session();
267 assert_eq!(session_role(&session), SessionRole::Primary);
268 }
269
270 #[test]
271 fn git_storage_meta_defaults_to_schema_v2_without_git() {
272 let session = make_session();
273 let bytes = build_git_storage_meta_json(&session);
274 let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
275 assert_eq!(parsed["schema_version"], 2);
276 assert!(parsed.get("git").is_none());
277 }
278
279 #[test]
280 fn git_storage_meta_includes_git_block_when_present() {
281 let session = make_session();
282 let git = GitMeta {
283 remote: Some("git@github.com:org/repo.git".to_string()),
284 repo_name: Some("org/repo".to_string()),
285 branch: Some("feature/x".to_string()),
286 head: Some("abcd1234".to_string()),
287 commits: vec!["abcd1234".to_string(), "beef5678".to_string()],
288 };
289 let bytes = build_git_storage_meta_json_with_git(&session, Some(&git));
290 let parsed: serde_json::Value = serde_json::from_slice(&bytes).expect("valid json");
291 assert_eq!(parsed["schema_version"], 2);
292 assert_eq!(parsed["git"]["repo_name"], "org/repo");
293 assert_eq!(parsed["git"]["commits"][0], "abcd1234");
294 }
295
296 #[test]
297 fn interaction_compressed_stats_keeps_only_interaction_events() {
298 let mut session = make_session();
299 let mut interactive_system = Event {
300 event_id: "e-system".to_string(),
301 timestamp: chrono::Utc::now(),
302 event_type: EventType::SystemMessage,
303 task_id: None,
304 content: Content::text("question"),
305 duration_ms: None,
306 attributes: HashMap::new(),
307 };
308 interactive_system.attributes.insert(
309 "source".to_string(),
310 Value::String("interactive_question".to_string()),
311 );
312
313 session.events = vec![
314 Event {
315 event_id: "e-user".to_string(),
316 timestamp: chrono::Utc::now(),
317 event_type: EventType::UserMessage,
318 task_id: None,
319 content: Content::text("hello"),
320 duration_ms: None,
321 attributes: HashMap::new(),
322 },
323 Event {
324 event_id: "e-tool-non-interactive".to_string(),
325 timestamp: chrono::Utc::now(),
326 event_type: EventType::ToolCall {
327 name: "write_file".to_string(),
328 },
329 task_id: None,
330 content: Content::text(""),
331 duration_ms: None,
332 attributes: HashMap::new(),
333 },
334 Event {
335 event_id: "e-tool-interactive".to_string(),
336 timestamp: chrono::Utc::now(),
337 event_type: EventType::ToolCall {
338 name: "request_user_input".to_string(),
339 },
340 task_id: None,
341 content: Content::text(""),
342 duration_ms: None,
343 attributes: HashMap::new(),
344 },
345 Event {
346 event_id: "e-interrupt".to_string(),
347 timestamp: chrono::Utc::now(),
348 event_type: EventType::Custom {
349 kind: "turn_aborted".to_string(),
350 },
351 task_id: None,
352 content: Content::text(""),
353 duration_ms: None,
354 attributes: HashMap::new(),
355 },
356 interactive_system,
357 ];
358 session.recompute_stats();
359 assert_eq!(session.stats.event_count, 5);
360
361 let compressed = interaction_compressed_stats(&session);
362 assert_eq!(compressed.event_count, 4);
363 assert_eq!(compressed.user_message_count, 1);
364 assert_eq!(compressed.tool_call_count, 1);
365 }
366
367 #[test]
368 fn interaction_compressed_session_retains_only_interaction_events() {
369 let mut session = make_session();
370 session.events = vec![
371 Event {
372 event_id: "e-user".to_string(),
373 timestamp: chrono::Utc::now(),
374 event_type: EventType::UserMessage,
375 task_id: None,
376 content: Content::text("hello"),
377 duration_ms: None,
378 attributes: HashMap::new(),
379 },
380 Event {
381 event_id: "e-tool-non-interactive".to_string(),
382 timestamp: chrono::Utc::now(),
383 event_type: EventType::ToolCall {
384 name: "write_file".to_string(),
385 },
386 task_id: None,
387 content: Content::text(""),
388 duration_ms: None,
389 attributes: HashMap::new(),
390 },
391 ];
392 session.recompute_stats();
393
394 let compressed = interaction_compressed_session(&session);
395 assert_eq!(compressed.events.len(), 1);
396 assert!(matches!(
397 compressed.events[0].event_type,
398 EventType::UserMessage
399 ));
400 assert_eq!(compressed.stats.event_count, 1);
401 }
402}