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::fs;
35use std::path::Path;
36
37/// Tracks session state for agent continuation.
38///
39/// This struct ensures session continuation is only attempted with the same agent.
40/// When the agent changes (due to fallback), the session must be reset.
41///
42/// NOTE: This is currently only used in tests. When this functionality is needed
43/// in production code, remove the `#[cfg(test)]` attribute.
44#[cfg(test)]
45#[derive(Debug, Clone, Default)]
46pub struct SessionState {
47    /// The session ID from the agent's output
48    session_id: Option<String>,
49    /// The agent name that created this session
50    agent_name: Option<String>,
51}
52
53#[cfg(test)]
54impl SessionState {
55    /// Create a new empty session state.
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Update the session state after a successful agent run.
61    ///
62    /// # Arguments
63    ///
64    /// * `session_id` - The session ID extracted from the agent's output
65    /// * `agent_name` - The name of the agent that ran
66    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    /// Get the session ID if it's valid for the given agent.
72    ///
73    /// Returns `None` if:
74    /// - No session ID has been set
75    /// - The agent name doesn't match (session belongs to a different agent)
76    ///
77    /// This ensures we only continue sessions with the same agent.
78    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    /// Check if we have a valid session for the given agent.
86    pub fn has_session_for_agent(&self, agent_name: &str) -> bool {
87        self.get_for_agent(agent_name).is_some()
88    }
89
90    /// Clear the session state.
91    ///
92    /// Call this when starting a completely new operation (not an XSD retry).
93    pub fn clear(&mut self) {
94        self.session_id = None;
95        self.agent_name = None;
96    }
97}
98
99/// Extract session ID from an OpenCode log file.
100///
101/// OpenCode outputs NDJSON with session IDs in the format:
102/// ```json
103/// {"type":"step_start","timestamp":1234567890,"sessionID":"ses_44f9562d4ffe",...}
104/// ```
105///
106/// We look for the first `sessionID` field and return it.
107///
108/// # Arguments
109///
110/// * `log_path` - Path to the log file containing OpenCode NDJSON output
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(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
121/// Extract session ID from OpenCode NDJSON content string.
122///
123/// This is the internal implementation that works on content directly,
124/// useful for testing and for cases where content is already in memory.
125pub fn extract_opencode_session_id_from_content(content: &str) -> Option<String> {
126    // Look for "sessionID":"ses_..." pattern
127    // We use a simple regex-free approach for performance
128    for line in content.lines() {
129        if let Some(session_id) = extract_session_id_from_json_line(line, "sessionID") {
130            // Validate it looks like an OpenCode session ID (starts with "ses_")
131            if session_id.starts_with("ses_") {
132                return Some(session_id);
133            }
134        }
135    }
136    None
137}
138
139/// Extract session ID from a Claude CLI log file.
140///
141/// Claude CLI outputs JSON with session IDs in the format:
142/// ```json
143/// {"type":"system","subtype":"init","session_id":"abc123"}
144/// ```
145///
146/// # Arguments
147///
148/// * `log_path` - Path to the log file containing Claude CLI JSON output
149///
150/// # Returns
151///
152/// * `Some(session_id)` if a valid session ID was found
153/// * `None` if no session ID could be extracted
154pub 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
159/// Extract session ID from Claude CLI JSON content string.
160pub fn extract_claude_session_id_from_content(content: &str) -> Option<String> {
161    // Look for "session_id":"..." pattern
162    for line in content.lines() {
163        if let Some(session_id) = extract_session_id_from_json_line(line, "session_id") {
164            // Claude session IDs don't have a specific prefix requirement
165            if !session_id.is_empty() {
166                return Some(session_id);
167            }
168        }
169    }
170    None
171}
172
173/// Extract a string value for a given key from a JSON line.
174///
175/// This is a simple, fast extraction that doesn't require full JSON parsing.
176/// It looks for `"key":"value"` patterns and extracts the value.
177///
178/// # Arguments
179///
180/// * `line` - A single line of JSON
181/// * `key` - The key to search for
182///
183/// # Returns
184///
185/// * `Some(value)` if the key was found with a string value
186/// * `None` if the key was not found or didn't have a string value
187fn extract_session_id_from_json_line(line: &str, key: &str) -> Option<String> {
188    // Build the search pattern: "key":"
189    let pattern = format!("\"{}\":\"", key);
190
191    // Find the pattern
192    let start_idx = line.find(&pattern)?;
193    let value_start = start_idx + pattern.len();
194
195    // Find the closing quote
196    let remaining = &line[value_start..];
197    let end_idx = remaining.find('"')?;
198
199    // Extract the value
200    let value = &remaining[..end_idx];
201
202    // Basic validation: non-empty and no control characters
203    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/// Result of extracting session info from a log file.
215#[derive(Debug, Clone)]
216pub struct SessionInfo {
217    /// The session ID extracted from the log file.
218    pub session_id: String,
219    /// The agent name extracted from the log file name.
220    pub agent_name: String,
221    /// The log file path (kept for debugging/future use).
222    #[allow(dead_code)]
223    pub log_file: std::path::PathBuf,
224}
225
226/// Find the most recent log file matching a prefix pattern and extract session info.
227///
228/// This function finds the log file and extracts the session ID from the content
229/// based on the JSON parser type. The agent name can be provided directly (preferred)
230/// or extracted from the log file name (fallback for backwards compatibility).
231///
232/// # Arguments
233///
234/// * `log_prefix` - The log file prefix (e.g., `.agent/logs/planning_1`)
235/// * `parser_type` - The JSON parser type to determine session ID format
236/// * `known_agent_name` - Optional agent registry name (e.g., "opencode/zai-coding-plan/glm-4.7").
237///   If provided, this is used directly instead of extracting from the log file name.
238///   This is preferred because log file names use sanitized names (slashes → hyphens)
239///   which can be ambiguous for agents with hyphenated provider names.
240///
241/// # Returns
242///
243/// * `Some(SessionInfo)` if session info was found
244/// * `None` if no session info could be extracted
245pub 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    // Find the most recent log file matching the prefix
253    let log_file = super::logfile::find_most_recent_logfile(log_prefix)?;
254
255    // Use the known agent name if provided, otherwise extract from log file name
256    let agent_name = if let Some(name) = known_agent_name {
257        name.to_string()
258    } else {
259        // Fallback: extract from log file name (may be sanitized/ambiguous)
260        super::logfile::extract_agent_name_from_logfile(&log_file, log_prefix)?
261    };
262
263    // Extract session ID based on parser type
264    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        // Other parsers don't support session continuation
268        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    // ===== SessionState tests =====
283
284    #[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        // Different agent should not get the session
306        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        // Old session should be replaced
327        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        // No session ID means no continuation possible
338        assert!(!state.has_session_for_agent("opencode"));
339    }
340
341    // ===== Session ID extraction tests =====
342
343    #[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        // Session ID without "ses_" prefix should be rejected
362        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        // Underscores and hyphens are allowed
400        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        // Control characters or other special chars should be rejected
408        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        // Empty string should be allowed by extraction but filtered by caller
418        assert_eq!(result, None); // Empty fails the all() check
419    }
420
421    // ===== Agent name extraction tests =====
422    // Note: These tests use the unified logfile module. The tests are kept here
423    // for session-specific integration testing.
424
425    #[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        // OpenCode agents with provider/model format
456        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}