ralph_workflow/pipeline/
session.rs1use std::fs;
35use std::path::Path;
36
37#[cfg(test)]
45#[derive(Debug, Clone, Default)]
46pub struct SessionState {
47 session_id: Option<String>,
49 agent_name: Option<String>,
51}
52
53#[cfg(test)]
54impl SessionState {
55 pub fn new() -> Self {
57 Self::default()
58 }
59
60 pub fn update(&mut self, session_id: Option<String>, agent_name: &str) {
67 self.session_id = session_id;
68 self.agent_name = Some(agent_name.to_string());
69 }
70
71 pub fn get_for_agent(&self, agent_name: &str) -> Option<&str> {
79 match (&self.session_id, &self.agent_name) {
80 (Some(id), Some(name)) if name == agent_name => Some(id.as_str()),
81 _ => None,
82 }
83 }
84
85 pub fn has_session_for_agent(&self, agent_name: &str) -> bool {
87 self.get_for_agent(agent_name).is_some()
88 }
89
90 pub fn clear(&mut self) {
94 self.session_id = None;
95 self.agent_name = None;
96 }
97}
98
99pub fn extract_opencode_session_id(log_path: &Path) -> Option<String> {
117 let content = fs::read_to_string(log_path).ok()?;
118 extract_opencode_session_id_from_content(&content)
119}
120
121pub fn extract_opencode_session_id_from_content(content: &str) -> Option<String> {
126 for line in content.lines() {
129 if let Some(session_id) = extract_session_id_from_json_line(line, "sessionID") {
130 if session_id.starts_with("ses_") {
132 return Some(session_id);
133 }
134 }
135 }
136 None
137}
138
139pub fn extract_claude_session_id(log_path: &Path) -> Option<String> {
155 let content = fs::read_to_string(log_path).ok()?;
156 extract_claude_session_id_from_content(&content)
157}
158
159pub fn extract_claude_session_id_from_content(content: &str) -> Option<String> {
161 for line in content.lines() {
163 if let Some(session_id) = extract_session_id_from_json_line(line, "session_id") {
164 if !session_id.is_empty() {
166 return Some(session_id);
167 }
168 }
169 }
170 None
171}
172
173fn extract_session_id_from_json_line(line: &str, key: &str) -> Option<String> {
188 let pattern = format!("\"{}\":\"", key);
190
191 let start_idx = line.find(&pattern)?;
193 let value_start = start_idx + pattern.len();
194
195 let remaining = &line[value_start..];
197 let end_idx = remaining.find('"')?;
198
199 let value = &remaining[..end_idx];
201
202 if !value.is_empty()
204 && value
205 .chars()
206 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
207 {
208 Some(value.to_string())
209 } else {
210 None
211 }
212}
213
214#[derive(Debug, Clone)]
216pub struct SessionInfo {
217 pub session_id: String,
219 pub agent_name: String,
221 #[allow(dead_code)]
223 pub log_file: std::path::PathBuf,
224}
225
226pub fn extract_session_info_from_log_prefix(
246 log_prefix: &Path,
247 parser_type: crate::agents::JsonParserType,
248 known_agent_name: Option<&str>,
249) -> Option<SessionInfo> {
250 use crate::agents::JsonParserType;
251
252 let log_file = super::logfile::find_most_recent_logfile(log_prefix)?;
254
255 let agent_name = if let Some(name) = known_agent_name {
257 name.to_string()
258 } else {
259 super::logfile::extract_agent_name_from_logfile(&log_file, log_prefix)?
261 };
262
263 let session_id = match parser_type {
265 JsonParserType::OpenCode => extract_opencode_session_id(&log_file),
266 JsonParserType::Claude => extract_claude_session_id(&log_file),
267 JsonParserType::Codex | JsonParserType::Gemini | JsonParserType::Generic => None,
269 }?;
270
271 Some(SessionInfo {
272 session_id,
273 agent_name,
274 log_file,
275 })
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
285 fn test_session_state_new_is_empty() {
286 let state = SessionState::new();
287 assert!(!state.has_session_for_agent("opencode"));
288 assert!(state.get_for_agent("opencode").is_none());
289 }
290
291 #[test]
292 fn test_session_state_update_and_get() {
293 let mut state = SessionState::new();
294 state.update(Some("ses_123".to_string()), "opencode");
295
296 assert!(state.has_session_for_agent("opencode"));
297 assert_eq!(state.get_for_agent("opencode"), Some("ses_123"));
298 }
299
300 #[test]
301 fn test_session_state_different_agent_returns_none() {
302 let mut state = SessionState::new();
303 state.update(Some("ses_123".to_string()), "opencode");
304
305 assert!(!state.has_session_for_agent("claude"));
307 assert!(state.get_for_agent("claude").is_none());
308 }
309
310 #[test]
311 fn test_session_state_clear() {
312 let mut state = SessionState::new();
313 state.update(Some("ses_123".to_string()), "opencode");
314 state.clear();
315
316 assert!(!state.has_session_for_agent("opencode"));
317 assert!(state.get_for_agent("opencode").is_none());
318 }
319
320 #[test]
321 fn test_session_state_update_replaces_previous() {
322 let mut state = SessionState::new();
323 state.update(Some("ses_123".to_string()), "opencode");
324 state.update(Some("ses_456".to_string()), "claude");
325
326 assert!(!state.has_session_for_agent("opencode"));
328 assert!(state.has_session_for_agent("claude"));
329 assert_eq!(state.get_for_agent("claude"), Some("ses_456"));
330 }
331
332 #[test]
333 fn test_session_state_none_session_id() {
334 let mut state = SessionState::new();
335 state.update(None, "opencode");
336
337 assert!(!state.has_session_for_agent("opencode"));
339 }
340
341 #[test]
344 fn test_extract_opencode_session_id_from_content() {
345 let content = r#"{"type":"step_start","timestamp":1768191337567,"sessionID":"ses_44f9562d4ffe","part":{"id":"prt_bb06aa45c001"}}
346{"type":"text","timestamp":1768191347231,"sessionID":"ses_44f9562d4ffe","part":{"text":"Hello"}}"#;
347
348 let session_id = extract_opencode_session_id_from_content(content);
349 assert_eq!(session_id, Some("ses_44f9562d4ffe".to_string()));
350 }
351
352 #[test]
353 fn test_extract_opencode_session_id_no_match() {
354 let content = r#"{"type":"text","part":{"text":"Hello"}}"#;
355 let session_id = extract_opencode_session_id_from_content(content);
356 assert_eq!(session_id, None);
357 }
358
359 #[test]
360 fn test_extract_opencode_session_id_invalid_prefix() {
361 let content = r#"{"type":"step_start","sessionID":"invalid_session"}"#;
363 let session_id = extract_opencode_session_id_from_content(content);
364 assert_eq!(session_id, None);
365 }
366
367 #[test]
368 fn test_extract_claude_session_id_from_content() {
369 let content = r#"{"type":"system","subtype":"init","session_id":"abc123"}
370{"type":"text","content":"Hello"}"#;
371
372 let session_id = extract_claude_session_id_from_content(content);
373 assert_eq!(session_id, Some("abc123".to_string()));
374 }
375
376 #[test]
377 fn test_extract_claude_session_id_no_match() {
378 let content = r#"{"type":"text","content":"Hello"}"#;
379 let session_id = extract_claude_session_id_from_content(content);
380 assert_eq!(session_id, None);
381 }
382
383 #[test]
384 fn test_extract_session_id_from_json_line() {
385 let line = r#"{"sessionID":"ses_abc123","other":"value"}"#;
386 let result = extract_session_id_from_json_line(line, "sessionID");
387 assert_eq!(result, Some("ses_abc123".to_string()));
388 }
389
390 #[test]
391 fn test_extract_session_id_from_json_line_not_found() {
392 let line = r#"{"other":"value"}"#;
393 let result = extract_session_id_from_json_line(line, "sessionID");
394 assert_eq!(result, None);
395 }
396
397 #[test]
398 fn test_extract_session_id_from_json_line_with_special_chars() {
399 let line = r#"{"sessionID":"ses_abc-123_def"}"#;
401 let result = extract_session_id_from_json_line(line, "sessionID");
402 assert_eq!(result, Some("ses_abc-123_def".to_string()));
403 }
404
405 #[test]
406 fn test_extract_session_id_rejects_invalid_chars() {
407 let line = r#"{"sessionID":"ses_abc<script>"}"#;
409 let result = extract_session_id_from_json_line(line, "sessionID");
410 assert_eq!(result, None);
411 }
412
413 #[test]
414 fn test_extract_session_id_empty_value() {
415 let line = r#"{"sessionID":""}"#;
416 let result = extract_session_id_from_json_line(line, "sessionID");
417 assert_eq!(result, None); }
420
421 #[test]
426 fn test_extract_agent_name_with_model_index() {
427 use std::path::PathBuf;
428 let log_file = PathBuf::from(".agent/logs/planning_1_ccs-glm_0.log");
429 let log_prefix = PathBuf::from(".agent/logs/planning_1");
430 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
431 assert_eq!(result, Some("ccs-glm".to_string()));
432 }
433
434 #[test]
435 fn test_extract_agent_name_without_model_index() {
436 use std::path::PathBuf;
437 let log_file = PathBuf::from(".agent/logs/planning_1_claude.log");
438 let log_prefix = PathBuf::from(".agent/logs/planning_1");
439 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
440 assert_eq!(result, Some("claude".to_string()));
441 }
442
443 #[test]
444 fn test_extract_agent_name_with_dashes() {
445 use std::path::PathBuf;
446 let log_file = PathBuf::from(".agent/logs/planning_1_glm-direct_2.log");
447 let log_prefix = PathBuf::from(".agent/logs/planning_1");
448 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
449 assert_eq!(result, Some("glm-direct".to_string()));
450 }
451
452 #[test]
453 fn test_extract_agent_name_opencode_provider() {
454 use std::path::PathBuf;
455 let log_file =
457 PathBuf::from(".agent/logs/planning_1_opencode-anthropic-claude-sonnet-4_0.log");
458 let log_prefix = PathBuf::from(".agent/logs/planning_1");
459 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
460 assert_eq!(
461 result,
462 Some("opencode-anthropic-claude-sonnet-4".to_string())
463 );
464 }
465
466 #[test]
467 fn test_extract_agent_name_wrong_prefix() {
468 use std::path::PathBuf;
469 let log_file = PathBuf::from(".agent/logs/review_1_claude_0.log");
470 let log_prefix = PathBuf::from(".agent/logs/planning_1");
471 let result = super::super::logfile::extract_agent_name_from_logfile(&log_file, &log_prefix);
472 assert_eq!(result, None);
473 }
474}