Skip to main content

ralph_workflow/pipeline/
session.rs

1//! Session management for agent continuation.
2//!
3//! This module provides utilities for extracting and managing session IDs
4//! from agent output logs, enabling session continuation for XSD retries.
5//!
6//! # Session Continuation
7//!
8//! When XSD validation fails, we want to continue the same agent session
9//! rather than starting a fresh one. This allows the AI to retain memory
10//! of its previous reasoning and the requirements it analyzed.
11//!
12//! # Supported Agents
13//!
14//! - **OpenCode**: Uses `--session <id>` flag with `sessionID` from NDJSON output
15//! - **Claude CLI**: Uses `--continue` or `--resume <id>` flag with `session_id` from JSON
16//!
17//! # Fallback Behavior
18//!
19//! **IMPORTANT**: Session continuation is agent-specific and does NOT affect the
20//! fallback mechanism. When an agent fails (rate limit, crash, etc.), the system
21//! falls back to a different agent with a **fresh session**.
22//!
23//! Session continuation only applies to **XSD retries within the same agent**:
24//!
25//! ```text
26//! Agent A: First attempt → XSD error → Retry (continue session) → XSD error → ...
27//!          ↓ (agent failure, e.g., rate limit)
28//! Agent B: Fresh session → XSD error → Retry (continue session) → ...
29//! ```
30//!
31//! The `SessionState` struct tracks both the session ID and the agent name,
32//! ensuring that session continuation is only attempted with the same agent.
33
34use std::path::{Path, PathBuf};
35
36/// Tracks session state for agent continuation.
37///
38/// This struct ensures session continuation is only attempted with the same agent.
39/// When the agent changes (due to fallback), the session must be reset.
40///
41/// NOTE: This is currently only used in tests. When this functionality is needed
42/// in production code, remove the `#[cfg(test)]` attribute.
43#[cfg(test)]
44#[derive(Debug, Clone, Default)]
45pub struct SessionState {
46    /// The session ID from the agent's output
47    session_id: Option<String>,
48    /// The agent name that created this session
49    agent_name: Option<String>,
50}
51
52#[cfg(test)]
53impl SessionState {
54    /// Create a new empty session state.
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Update the session state after a successful agent run.
60    ///
61    /// # Arguments
62    ///
63    /// * `session_id` - The session ID extracted from the agent's output
64    /// * `agent_name` - The name of the agent that ran
65    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    /// Get the session ID if it's valid for the given agent.
71    ///
72    /// Returns `None` if:
73    /// - No session ID has been set
74    /// - The agent name doesn't match (session belongs to a different agent)
75    ///
76    /// This ensures we only continue sessions with the same agent.
77    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    /// Check if we have a valid session for the given agent.
85    pub fn has_session_for_agent(&self, agent_name: &str) -> bool {
86        self.get_for_agent(agent_name).is_some()
87    }
88
89    /// Clear the session state.
90    ///
91    /// Call this when starting a completely new operation (not an XSD retry).
92    pub fn clear(&mut self) {
93        self.session_id = None;
94        self.agent_name = None;
95    }
96}
97
98/// Extract session ID from an OpenCode log file.
99///
100/// OpenCode outputs NDJSON with session IDs in the format:
101/// ```json
102/// {"type":"step_start","timestamp":1234567890,"sessionID":"ses_44f9562d4ffe",...}
103/// ```
104///
105/// We look for the first `sessionID` field and return it.
106///
107/// # Arguments
108///
109/// * `log_path` - Path to the log file containing OpenCode NDJSON output
110/// * `workspace` - Workspace for file operations
111///
112/// # Returns
113///
114/// * `Some(session_id)` if a valid session ID was found
115/// * `None` if no session ID could be extracted
116pub 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
124/// Extract session ID from OpenCode NDJSON content string.
125///
126/// This is the internal implementation that works on content directly,
127/// useful for testing and for cases where content is already in memory.
128pub fn extract_opencode_session_id_from_content(content: &str) -> Option<String> {
129    // Look for "sessionID":"ses_..." pattern
130    // We use a simple regex-free approach for performance
131    for line in content.lines() {
132        if let Some(session_id) = extract_session_id_from_json_line(line, "sessionID") {
133            // Validate it looks like an OpenCode session ID (starts with "ses_")
134            if session_id.starts_with("ses_") {
135                return Some(session_id);
136            }
137        }
138    }
139    None
140}
141
142/// Extract session ID from a Claude CLI log file.
143///
144/// Claude CLI outputs JSON with session IDs in the format:
145/// ```json
146/// {"type":"system","subtype":"init","session_id":"abc123"}
147/// ```
148///
149/// # Arguments
150///
151/// * `log_path` - Path to the log file containing Claude CLI JSON output
152/// * `workspace` - Workspace for file operations
153///
154/// # Returns
155///
156/// * `Some(session_id)` if a valid session ID was found
157/// * `None` if no session ID could be extracted
158pub 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
166/// Extract session ID from Claude CLI JSON content string.
167pub fn extract_claude_session_id_from_content(content: &str) -> Option<String> {
168    // Look for "session_id":"..." pattern
169    for line in content.lines() {
170        if let Some(session_id) = extract_session_id_from_json_line(line, "session_id") {
171            // Claude session IDs don't have a specific prefix requirement
172            if !session_id.is_empty() {
173                return Some(session_id);
174            }
175        }
176    }
177    None
178}
179
180/// Extract a string value for a given key from a JSON line.
181///
182/// This is a simple, fast extraction that doesn't require full JSON parsing.
183/// It looks for `"key":"value"` patterns and extracts the value.
184///
185/// # Arguments
186///
187/// * `line` - A single line of JSON
188/// * `key` - The key to search for
189///
190/// # Returns
191///
192/// * `Some(value)` if the key was found with a string value
193/// * `None` if the key was not found or didn't have a string value
194fn extract_session_id_from_json_line(line: &str, key: &str) -> Option<String> {
195    // Build the search pattern: "key":"
196    let pattern = format!("\"{}\":\"", key);
197
198    // Find the pattern
199    let start_idx = line.find(&pattern)?;
200    let value_start = start_idx + pattern.len();
201
202    // Find the closing quote
203    let remaining = &line[value_start..];
204    let end_idx = remaining.find('"')?;
205
206    // Extract the value
207    let value = &remaining[..end_idx];
208
209    // Basic validation: non-empty and no control characters
210    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/// Result of extracting session info from a log file.
222#[derive(Debug, Clone)]
223pub struct SessionInfo {
224    /// The session ID extracted from the log file.
225    pub session_id: String,
226    /// The agent name extracted from the log file name.
227    pub agent_name: String,
228    /// The log file path (kept for debugging/future use).
229    #[cfg(any(test, feature = "test-utils"))]
230    pub log_file: std::path::PathBuf,
231}
232
233/// Find log files matching a prefix pattern and extract session info.
234///
235/// This function finds log files and extracts the session ID from the content
236/// based on the JSON parser type. The agent name can be provided directly (preferred)
237/// or extracted from the log file name (fallback for backwards compatibility).
238///
239/// # Arguments
240///
241/// * `log_prefix` - The log file prefix (e.g., `.agent/logs/planning_1`)
242/// * `parser_type` - The JSON parser type to determine session ID format
243/// * `known_agent_name` - Optional agent registry name (e.g., "opencode/zai-coding-plan/glm-4.7").
244///   If provided, this is used directly instead of extracting from the log file name.
245///   This is preferred because log file names use sanitized names (slashes → hyphens)
246///   which can be ambiguous for agents with hyphenated provider names.
247/// * `workspace` - Workspace for file operations
248///
249/// # Returns
250///
251/// * `Some(SessionInfo)` if session info was found
252/// * `None` if no session info could be extracted
253pub 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    // Get all log files matching the prefix
262    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                    // Match files that start with our prefix, have more content, and end with .log
272                    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    // Sort by filename for deterministic ordering
288    log_files.sort();
289
290    // Use the known agent name if provided
291    let agent_name = if let Some(name) = known_agent_name {
292        name.to_string()
293    } else {
294        // If no known agent name, try to extract from the first log file
295        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    // Try to extract session ID from each log file
303    // The first file with a valid session ID wins
304    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            // Other parsers don't support session continuation
309            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    // ===== SessionState tests =====
333
334    #[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        // Different agent should not get the session
356        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        // Old session should be replaced
377        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        // No session ID means no continuation possible
388        assert!(!state.has_session_for_agent("opencode"));
389    }
390
391    // ===== Session ID extraction tests =====
392
393    #[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        // Session ID without "ses_" prefix should be rejected
412        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        // Underscores and hyphens are allowed
450        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        // Control characters or other special chars should be rejected
458        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        // Empty string should be allowed by extraction but filtered by caller
468        assert_eq!(result, None); // Empty fails the all() check
469    }
470
471    // ===== Agent name extraction tests =====
472    // Note: These tests use the unified logfile module. The tests are kept here
473    // for session-specific integration testing.
474
475    #[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        // OpenCode agents with provider/model format
506        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}