1use std::path::{Path, PathBuf};
35
36#[cfg(test)]
44#[derive(Debug, Clone, Default)]
45pub struct SessionState {
46 session_id: Option<String>,
48 agent_name: Option<String>,
50}
51
52#[cfg(test)]
53impl SessionState {
54 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn update(&mut self, session_id: Option<String>, agent_name: &str) {
66 self.session_id = session_id;
67 self.agent_name = Some(agent_name.to_string());
68 }
69
70 pub fn get_for_agent(&self, agent_name: &str) -> Option<&str> {
78 match (&self.session_id, &self.agent_name) {
79 (Some(id), Some(name)) if name == agent_name => Some(id.as_str()),
80 _ => None,
81 }
82 }
83
84 pub fn has_session_for_agent(&self, agent_name: &str) -> bool {
86 self.get_for_agent(agent_name).is_some()
87 }
88
89 pub fn clear(&mut self) {
93 self.session_id = None;
94 self.agent_name = None;
95 }
96}
97
98pub fn extract_opencode_session_id(
117 log_path: &Path,
118 workspace: &dyn crate::workspace::Workspace,
119) -> Option<String> {
120 let content = workspace.read(log_path).ok()?;
121 extract_opencode_session_id_from_content(&content)
122}
123
124pub fn extract_opencode_session_id_from_content(content: &str) -> Option<String> {
129 for line in content.lines() {
132 if let Some(session_id) = extract_session_id_from_json_line(line, "sessionID") {
133 if session_id.starts_with("ses_") {
135 return Some(session_id);
136 }
137 }
138 }
139 None
140}
141
142pub fn extract_claude_session_id(
159 log_path: &Path,
160 workspace: &dyn crate::workspace::Workspace,
161) -> Option<String> {
162 let content = workspace.read(log_path).ok()?;
163 extract_claude_session_id_from_content(&content)
164}
165
166pub fn extract_claude_session_id_from_content(content: &str) -> Option<String> {
168 for line in content.lines() {
170 if let Some(session_id) = extract_session_id_from_json_line(line, "session_id") {
171 if !session_id.is_empty() {
173 return Some(session_id);
174 }
175 }
176 }
177 None
178}
179
180fn extract_session_id_from_json_line(line: &str, key: &str) -> Option<String> {
195 let pattern = format!("\"{}\":\"", key);
197
198 let start_idx = line.find(&pattern)?;
200 let value_start = start_idx + pattern.len();
201
202 let remaining = &line[value_start..];
204 let end_idx = remaining.find('"')?;
205
206 let value = &remaining[..end_idx];
208
209 if !value.is_empty()
211 && value
212 .chars()
213 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
214 {
215 Some(value.to_string())
216 } else {
217 None
218 }
219}
220
221#[derive(Debug, Clone)]
223pub struct SessionInfo {
224 pub session_id: String,
226 pub agent_name: String,
228 #[cfg(any(test, feature = "test-utils"))]
230 pub log_file: std::path::PathBuf,
231}
232
233pub fn extract_session_info_from_log_prefix(
254 log_prefix: &Path,
255 parser_type: crate::agents::JsonParserType,
256 known_agent_name: Option<&str>,
257 workspace: &dyn crate::workspace::Workspace,
258) -> Option<SessionInfo> {
259 use crate::agents::JsonParserType;
260
261 let parent = log_prefix.parent().unwrap_or(Path::new("."));
263 let prefix_str = log_prefix.file_name().and_then(|s| s.to_str())?;
264
265 let mut log_files: Vec<PathBuf> = Vec::new();
266
267 if let Ok(entries) = workspace.read_dir(parent) {
268 for entry in entries {
269 if entry.is_file() {
270 if let Some(filename) = entry.file_name().and_then(|s| s.to_str()) {
271 if filename.starts_with(prefix_str)
273 && filename.len() > prefix_str.len()
274 && filename.ends_with(".log")
275 {
276 log_files.push(entry.path().to_path_buf());
277 }
278 }
279 }
280 }
281 }
282
283 if log_files.is_empty() {
284 return None;
285 }
286
287 log_files.sort();
289
290 let agent_name = if let Some(name) = known_agent_name {
292 name.to_string()
293 } else {
294 if let Some(first_log) = log_files.first() {
296 super::logfile::extract_agent_name_from_logfile(first_log, log_prefix)?
297 } else {
298 return None;
299 }
300 };
301
302 for log_file in &log_files {
305 let session_id = match parser_type {
306 JsonParserType::OpenCode => extract_opencode_session_id(log_file, workspace),
307 JsonParserType::Claude => extract_claude_session_id(log_file, workspace),
308 JsonParserType::Codex | JsonParserType::Gemini | JsonParserType::Generic => None,
310 };
311
312 if let Some(session_id) = session_id {
313 #[cfg(any(test, feature = "test-utils"))]
314 let log_file = log_file.to_path_buf();
315
316 return Some(SessionInfo {
317 session_id,
318 agent_name: agent_name.clone(),
319 #[cfg(any(test, feature = "test-utils"))]
320 log_file,
321 });
322 }
323 }
324
325 None
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
335 fn test_session_state_new_is_empty() {
336 let state = SessionState::new();
337 assert!(!state.has_session_for_agent("opencode"));
338 assert!(state.get_for_agent("opencode").is_none());
339 }
340
341 #[test]
342 fn test_session_state_update_and_get() {
343 let mut state = SessionState::new();
344 state.update(Some("ses_123".to_string()), "opencode");
345
346 assert!(state.has_session_for_agent("opencode"));
347 assert_eq!(state.get_for_agent("opencode"), Some("ses_123"));
348 }
349
350 #[test]
351 fn test_session_state_different_agent_returns_none() {
352 let mut state = SessionState::new();
353 state.update(Some("ses_123".to_string()), "opencode");
354
355 assert!(!state.has_session_for_agent("claude"));
357 assert!(state.get_for_agent("claude").is_none());
358 }
359
360 #[test]
361 fn test_session_state_clear() {
362 let mut state = SessionState::new();
363 state.update(Some("ses_123".to_string()), "opencode");
364 state.clear();
365
366 assert!(!state.has_session_for_agent("opencode"));
367 assert!(state.get_for_agent("opencode").is_none());
368 }
369
370 #[test]
371 fn test_session_state_update_replaces_previous() {
372 let mut state = SessionState::new();
373 state.update(Some("ses_123".to_string()), "opencode");
374 state.update(Some("ses_456".to_string()), "claude");
375
376 assert!(!state.has_session_for_agent("opencode"));
378 assert!(state.has_session_for_agent("claude"));
379 assert_eq!(state.get_for_agent("claude"), Some("ses_456"));
380 }
381
382 #[test]
383 fn test_session_state_none_session_id() {
384 let mut state = SessionState::new();
385 state.update(None, "opencode");
386
387 assert!(!state.has_session_for_agent("opencode"));
389 }
390
391 #[test]
394 fn test_extract_opencode_session_id_from_content() {
395 let content = r#"{"type":"step_start","timestamp":1768191337567,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06aa45c001"}}
396{"type":"text","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{"text":"Hello"}}"#;
397
398 let session_id = extract_opencode_session_id_from_content(content);
399 assert_eq!(session_id, Some("ses_44f9562d4ffe".to_string()));
400 }
401
402 #[test]
403 fn test_extract_opencode_session_id_no_match() {
404 let content = r#"{"type":"text","part":{"text":"Hello"}}"#;
405 let session_id = extract_opencode_session_id_from_content(content);
406 assert_eq!(session_id, None);
407 }
408
409 #[test]
410 fn test_extract_opencode_session_id_invalid_prefix() {
411 let content = r#"{"type":"step_start","sessionID":"invalid_session"}"#;
413 let session_id = extract_opencode_session_id_from_content(content);
414 assert_eq!(session_id, None);
415 }
416
417 #[test]
418 fn test_extract_claude_session_id_from_content() {
419 let content = r#"{"type":"system","subtype":"init","session_id":"abc123"}
420{"type":"text","content":"Hello"}"#;
421
422 let session_id = extract_claude_session_id_from_content(content);
423 assert_eq!(session_id, Some("abc123".to_string()));
424 }
425
426 #[test]
427 fn test_extract_claude_session_id_no_match() {
428 let content = r#"{"type":"text","content":"Hello"}"#;
429 let session_id = extract_claude_session_id_from_content(content);
430 assert_eq!(session_id, None);
431 }
432
433 #[test]
434 fn test_extract_session_id_from_json_line() {
435 let line = r#"{"sessionID":"ses_abc123","other":"value"}"#;
436 let result = extract_session_id_from_json_line(line, "sessionID");
437 assert_eq!(result, Some("ses_abc123".to_string()));
438 }
439
440 #[test]
441 fn test_extract_session_id_from_json_line_not_found() {
442 let line = r#"{"other":"value"}"#;
443 let result = extract_session_id_from_json_line(line, "sessionID");
444 assert_eq!(result, None);
445 }
446
447 #[test]
448 fn test_extract_session_id_from_json_line_with_special_chars() {
449 let line = r#"{"sessionID":"ses_abc-123_def"}"#;
451 let result = extract_session_id_from_json_line(line, "sessionID");
452 assert_eq!(result, Some("ses_abc-123_def".to_string()));
453 }
454
455 #[test]
456 fn test_extract_session_id_rejects_invalid_chars() {
457 let line = r#"{"sessionID":"ses_abc<script>"}"#;
459 let result = extract_session_id_from_json_line(line, "sessionID");
460 assert_eq!(result, None);
461 }
462
463 #[test]
464 fn test_extract_session_id_empty_value() {
465 let line = r#"{"sessionID":""}"#;
466 let result = extract_session_id_from_json_line(line, "sessionID");
467 assert_eq!(result, None); }
470
471 #[test]
476 fn test_extract_agent_name_with_model_index() {
477 use std::path::PathBuf;
478 let log_file = PathBuf::from(".agent/logs/planning_1_ccs-glm_0.log");
479 let log_prefix = PathBuf::from(".agent/logs/planning_1");
480 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
481 assert_eq!(result, Some("ccs-glm".to_string()));
482 }
483
484 #[test]
485 fn test_extract_agent_name_without_model_index() {
486 use std::path::PathBuf;
487 let log_file = PathBuf::from(".agent/logs/planning_1_claude.log");
488 let log_prefix = PathBuf::from(".agent/logs/planning_1");
489 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
490 assert_eq!(result, Some("claude".to_string()));
491 }
492
493 #[test]
494 fn test_extract_agent_name_with_dashes() {
495 use std::path::PathBuf;
496 let log_file = PathBuf::from(".agent/logs/planning_1_glm-direct_2.log");
497 let log_prefix = PathBuf::from(".agent/logs/planning_1");
498 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
499 assert_eq!(result, Some("glm-direct".to_string()));
500 }
501
502 #[test]
503 fn test_extract_agent_name_opencode_provider() {
504 use std::path::PathBuf;
505 let log_file =
507 PathBuf::from(".agent/logs/planning_1_opencode-anthropic-claude-sonnet-4_0.log");
508 let log_prefix = PathBuf::from(".agent/logs/planning_1");
509 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
510 assert_eq!(
511 result,
512 Some("opencode-anthropic-claude-sonnet-4".to_string())
513 );
514 }
515
516 #[test]
517 fn test_extract_agent_name_wrong_prefix() {
518 use std::path::PathBuf;
519 let log_file = PathBuf::from(".agent/logs/review_1_claude_0.log");
520 let log_prefix = PathBuf::from(".agent/logs/planning_1");
521 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
522 assert_eq!(result, None);
523 }
524}