Skip to main content

ai_agent/
session_restore.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/utils/sessionRestore.ts
2//! Session restore functionality for resuming conversations from NDJSON transcripts.
3//!
4//! This module provides comprehensive session restoration including:
5//! - File history state restoration
6//! - Attribution state restoration
7//! - Context-collapse commit/snapshot restoration
8//! - TODO list extraction from transcript
9//! - Agent restoration (type, name, color, model override)
10//! - Coordinator mode matching
11//! - Resume consistency checks
12
13use crate::cli_ndjson_safe_stringify::serialize_to_ndjson;
14use crate::coordinator::coordinator_mode::{is_coordinator_mode, match_session_mode};
15use crate::constants::tools::TODO_WRITE_TOOL_NAME;
16use crate::error::AgentError;
17use crate::session::{get_jsonl_path, get_session_path, get_sessions_dir, SessionEntry, SessionMetadata};
18use crate::types::api_types::{Message, MessageRole};
19use crate::types::logs::{
20    AttributionSnapshotMessage, ContextCollapseCommitEntry, ContextCollapseSnapshotEntry,
21    FileHistorySnapshot, PersistedWorktreeSession,
22};
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::path::PathBuf;
26
27// ---------------------------------------------------------------------------
28// Public types
29// ---------------------------------------------------------------------------
30
31/// A TODO item extracted from a TodoWrite tool call in the transcript.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TodoEntry {
34    pub id: String,
35    pub content: String,
36    pub done: bool,
37}
38
39/// Information about a standalone agent's visual context (name and color).
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct StandaloneAgentContext {
42    pub name: String,
43    /// Display color. `None` means use the default color.
44    pub color: Option<String>,
45}
46
47/// Agent restoration information extracted from session transcript entries.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AgentRestoreInfo {
50    /// The agent type/setting (e.g. "reviewer", "worker")
51    pub agent_type: String,
52    /// Human-readable agent name
53    pub agent_name: Option<String>,
54    /// Agent display color
55    pub agent_color: Option<String>,
56    /// Model override if specified on the agent
57    pub model_override: Option<String>,
58}
59
60/// Consistency check result for session resume.
61#[derive(Debug, Clone)]
62pub struct ConsistencyCheck {
63    /// Whether the session can be safely resumed.
64    pub can_resume: bool,
65    /// List of issues found during the consistency check.
66    pub issues: Vec<String>,
67}
68
69/// Result of restoring a session from its NDJSON transcript log.
70#[derive(Debug, Clone)]
71pub struct SessionRestoreResult {
72    /// Messages extracted from the session transcript.
73    pub messages: Vec<Message>,
74    /// File history snapshots for state restoration.
75    pub file_history_snapshots: Vec<FileHistorySnapshot>,
76    /// Attribution snapshots for commit attribution state.
77    pub attribution_snapshots: Vec<AttributionSnapshotMessage>,
78    /// Context-collapse commit entries for rebuilding the collapsed view.
79    pub context_collapse_commits: Vec<ContextCollapseCommitEntry>,
80    /// Context-collapse staged snapshot.
81    pub context_collapse_snapshot: Option<ContextCollapseSnapshotEntry>,
82    /// TODO items extracted from the last TodoWrite tool call.
83    pub todo_items: Vec<String>,
84    /// Agent restoration info if the session used a custom agent.
85    pub agent_info: Option<AgentRestoreInfo>,
86    /// Standalone agent visual context (name + color).
87    pub standalone_agent_context: Option<StandaloneAgentContext>,
88    /// Session metadata (model, cwd, etc.).
89    pub metadata: Option<SessionMetadata>,
90    /// Session mode from the transcript (coordinator or normal).
91    pub mode: Option<String>,
92    /// Worktree session state.
93    pub worktree_session: Option<PersistedWorktreeSession>,
94    /// Custom session title.
95    pub custom_title: Option<String>,
96    /// Session tag.
97    pub tag: Option<String>,
98    /// Number of entries skipped due to parse errors.
99    pub skipped_count: usize,
100}
101
102// ---------------------------------------------------------------------------
103// Main restore function
104// ---------------------------------------------------------------------------
105
106/// Restore a complete session state from its NDJSON transcript log.
107///
108/// This is the primary entry point for session resume. It loads the session
109/// transcript, parses all entry types, and returns a structured result
110/// containing messages, file history snapshots, attribution snapshots,
111/// context-collapse commits, TODO items, and agent info.
112///
113/// # Arguments
114///
115/// * `session_id` - The session identifier.
116///
117/// # Returns
118///
119/// A `SessionRestoreResult` with all extracted state, or an error if the
120/// session cannot be loaded.
121pub async fn restore_session_from_log(session_id: &str) -> Result<SessionRestoreResult, AgentError> {
122    let entries = load_transcript_entries(session_id).await?;
123
124    // Extract entries by type
125    let mut file_history_snapshots: Vec<FileHistorySnapshot> = Vec::new();
126    let mut attribution_snapshots: Vec<AttributionSnapshotMessage> = Vec::new();
127    let mut context_collapse_commits: Vec<ContextCollapseCommitEntry> = Vec::new();
128    let mut context_collapse_snapshot: Option<ContextCollapseSnapshotEntry> = None;
129    let mut worktree_session: Option<PersistedWorktreeSession> = None;
130    let mut custom_title: Option<String> = None;
131    let mut tag: Option<String> = None;
132
133    // Agent-related fields
134    let mut agent_setting: Option<String> = None;
135    let mut agent_name: Option<String> = None;
136    let mut agent_color: Option<String> = None;
137
138    let mut metadata: Option<SessionMetadata> = None;
139    let mut mode: Option<String> = None;
140    let mut messages: Vec<Message> = Vec::new();
141
142    for entry in &entries {
143        let entry_type = match &entry.entry_type {
144            Some(t) => t.as_str(),
145            None => continue,
146        };
147
148        match entry_type {
149            "file-history-snapshot" => {
150                if let Some(data) = &entry.data {
151                    if let Ok(snapshot) =
152                        serde_json::from_value::<FileHistorySnapshot>(data.clone())
153                    {
154                        file_history_snapshots.push(snapshot);
155                    }
156                }
157            }
158            "attribution-snapshot" => {
159                if let Some(data) = &entry.data {
160                    if let Ok(snapshot) = serde_json::from_value::<AttributionSnapshotMessage>(data.clone()) {
161                        attribution_snapshots.push(snapshot);
162                    }
163                }
164            }
165            "marble-origami-commit" => {
166                if let Some(data) = &entry.data {
167                    if let Ok(commit) = serde_json::from_value::<ContextCollapseCommitEntry>(data.clone()) {
168                        context_collapse_commits.push(commit);
169                    }
170                }
171            }
172            "marble-origami-snapshot" => {
173                if let Some(data) = &entry.data {
174                    if let Ok(snapshot) =
175                        serde_json::from_value::<ContextCollapseSnapshotEntry>(data.clone())
176                    {
177                        context_collapse_snapshot = Some(snapshot);
178                    }
179                }
180            }
181            "worktree-state" => {
182                if let Some(data) = &entry.data {
183                    if let Ok(ws) = serde_json::from_value::<PersistedWorktreeSession>(data.clone()) {
184                        worktree_session = Some(ws);
185                    }
186                }
187            }
188            "agent-setting" => {
189                if let Some(data) = &entry.data {
190                    if let Some(setting) = data.get("agentSetting").and_then(|v| v.as_str()) {
191                        agent_setting = Some(setting.to_string());
192                    }
193                }
194            }
195            "agent-name" => {
196                if let Some(data) = &entry.data {
197                    if let Some(name) = data.get("agentName").and_then(|v| v.as_str()) {
198                        agent_name = Some(name.to_string());
199                    }
200                }
201            }
202            "agent-color" => {
203                if let Some(data) = &entry.data {
204                    if let Some(color) = data.get("agentColor").and_then(|v| v.as_str()) {
205                        agent_color = Some(color.to_string());
206                    }
207                }
208            }
209            "custom-title" => {
210                if let Some(data) = &entry.data {
211                    if let Some(title) = data.get("customTitle").and_then(|v| v.as_str()) {
212                        custom_title = Some(title.to_string());
213                    }
214                }
215            }
216            "tag" => {
217                if let Some(data) = &entry.data {
218                    if let Some(t) = data.get("tag").and_then(|v| v.as_str()) {
219                        tag = Some(t.to_string());
220                    }
221                }
222            }
223            "mode" => {
224                if let Some(data) = &entry.data {
225                    if let Some(m) = data.get("mode").and_then(|v| v.as_str()) {
226                        mode = Some(m.to_string());
227                    }
228                }
229            }
230            "metadata" => {
231                if let Some(data) = &entry.data {
232                    if let Ok(md) = serde_json::from_value::<SessionMetadata>(data.clone()) {
233                        metadata = Some(md);
234                    }
235                }
236            }
237            "message" => {
238                if let Some(data) = &entry.data {
239                    if let Ok(msg) = serde_json::from_value::<Message>(data.clone()) {
240                        messages.push(msg);
241                    }
242                }
243            }
244            _ => {}
245        }
246    }
247
248    // Extract TODO items from messages
249    let todo_items = extract_todo_from_transcript(&entries);
250
251    // Restore agent info
252    let agent_info = restore_agent_from_session_with_fields(
253        agent_setting,
254        agent_name.clone(),
255        agent_color.clone(),
256    );
257
258    // Compute standalone agent context
259    let standalone_agent_context = compute_standalone_agent_context(agent_name.as_deref(), agent_color.as_deref());
260
261    // Restore context-collapse state in the global store
262    // This mirrors TypeScript's unconditional restoreFromEntries call.
263    // It must run before the first query() so projectView() can rebuild
264    // the collapsed view from the resumed messages.
265    let _ = &context_collapse_commits;
266    let _ = &context_collapse_snapshot;
267    // The caller should invoke crate::services::context_collapse::persist::restore_from_entries
268    // with the extracted commits and snapshot. We provide the data here.
269
270    Ok(SessionRestoreResult {
271        messages,
272        file_history_snapshots,
273        attribution_snapshots,
274        context_collapse_commits,
275        context_collapse_snapshot,
276        todo_items,
277        agent_info,
278        standalone_agent_context,
279        metadata,
280        mode,
281        worktree_session,
282        custom_title,
283        tag,
284        skipped_count: 0,
285    })
286}
287
288/// Load and parse all NDJSON transcript entries for a session.
289async fn load_transcript_entries(session_id: &str) -> Result<Vec<SessionEntry>, AgentError> {
290    let path = get_jsonl_path(session_id);
291    let content = match tokio::fs::read_to_string(&path).await {
292        Ok(c) => c,
293        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
294            return Err(AgentError::Session(format!(
295                "Session '{}' not found: no transcript at {:?}",
296                session_id, path
297            )));
298        }
299        Err(e) => return Err(AgentError::Io(e)),
300    };
301
302    let mut entries = Vec::new();
303    for line in content.lines() {
304        let line = line.trim().to_string();
305        if line.is_empty() {
306            continue;
307        }
308        let entry: SessionEntry = match serde_json::from_str(&line) {
309            Ok(e) => e,
310            Err(_) => continue,
311        };
312        entries.push(entry);
313    }
314
315    Ok(entries)
316}
317
318// ---------------------------------------------------------------------------
319// TODO extraction
320// ---------------------------------------------------------------------------
321
322/// Extract TODO items from the last TodoWrite tool call in the transcript.
323///
324/// Scans session entries in reverse order to find the last assistant message
325/// containing a TodoWrite tool_use block, then extracts the TODO item contents.
326/// This mirrors the TypeScript `extractTodosFromTranscript` which scans
327/// `Message[]` for `tool_use` blocks with `name === TODO_WRITE_TOOL_NAME`.
328///
329/// # Arguments
330///
331/// * `entries` - All parsed session transcript entries.
332///
333/// # Returns
334///
335/// A vector of TODO item content strings. Empty if no TodoWrite tool call
336/// was found or the input was invalid.
337pub fn extract_todo_from_transcript(entries: &[SessionEntry]) -> Vec<String> {
338    // Scan entries in reverse to find the last TodoWrite tool call.
339    for entry in entries.iter().rev() {
340        let entry_type = match &entry.entry_type {
341            Some(t) if t == "message" => "message",
342            _ => continue,
343        };
344        let data = match &entry.data {
345            Some(d) => d,
346            None => continue,
347        };
348
349        // Check if this is an assistant message
350        let role = data.get("role").and_then(|r| r.as_str());
351        if role != Some("assistant") {
352            continue;
353        }
354
355        // Look for TodoWrite tool call in tool_calls array
356        let tool_calls = data.get("tool_calls");
357        if let Some(tc_arr) = tool_calls.and_then(|arr| arr.as_array()) {
358            for tc in tc_arr {
359                let name = tc.get("name").and_then(|n| n.as_str());
360                if name == Some(TODO_WRITE_TOOL_NAME) {
361                    return parse_todos_from_tool_input(tc);
362                }
363            }
364        }
365    }
366    Vec::new()
367}
368
369/// Parse TODO items from a tool call's input.
370///
371/// Handles both structured `input.todos` and flat JSON objects.
372fn parse_todos_from_tool_input(tool_call: &serde_json::Value) -> Vec<String> {
373    // Try input.todos first (structured format)
374    if let Some(input) = tool_call.get("input") {
375        if let Some(todos) = input.get("todos") {
376            if let Some(arr) = todos.as_array() {
377                let mut items = Vec::new();
378                for item in arr {
379                    if let Some(content) = item.get("content").and_then(|c| c.as_str()) {
380                        items.push(content.to_string());
381                    } else if let Some(content) = item.as_str() {
382                        items.push(content.to_string());
383                    }
384                }
385                if !items.is_empty() {
386                    return items;
387                }
388            }
389        }
390    }
391
392    // Fallback: try to extract content from the tool call itself
393    Vec::new()
394}
395
396// ---------------------------------------------------------------------------
397// Agent restoration
398// ---------------------------------------------------------------------------
399
400/// Restore agent information from session transcript entries.
401///
402/// Scans the full entry list for agent-setting, agent-name, and agent-color
403/// entries to reconstruct the agent configuration that was active during
404/// the session. This mirrors TypeScript's `restoreAgentFromSession`.
405///
406/// # Arguments
407///
408/// * `entries` - All parsed session transcript entries.
409///
410/// # Returns
411///
412/// `Some(AgentRestoreInfo)` if agent-related entries were found, `None`
413/// if the session used no custom agent.
414pub fn restore_agent_from_session(entries: &[SessionEntry]) -> Option<AgentRestoreInfo> {
415    let mut agent_setting: Option<String> = None;
416    let mut agent_name: Option<String> = None;
417    let mut agent_color: Option<String> = None;
418
419    for entry in entries {
420        let entry_type = match &entry.entry_type {
421            Some(t) => t.as_str(),
422            None => continue,
423        };
424        let data = match &entry.data {
425            Some(d) => d,
426            None => continue,
427        };
428
429        match entry_type {
430            "agent-setting" => {
431                if let Some(setting) = data.get("agentSetting").and_then(|v| v.as_str()) {
432                    agent_setting = Some(setting.to_string());
433                }
434            }
435            "agent-name" => {
436                if let Some(name) = data.get("agentName").and_then(|v| v.as_str()) {
437                    agent_name = Some(name.to_string());
438                }
439            }
440            "agent-color" => {
441                if let Some(color) = data.get("agentColor").and_then(|v| v.as_str()) {
442                    agent_color = Some(color.to_string());
443                }
444            }
445            _ => {}
446        }
447    }
448
449    restore_agent_from_session_with_fields(agent_setting, agent_name, agent_color)
450}
451
452/// Internal helper to build AgentRestoreInfo from extracted fields.
453fn restore_agent_from_session_with_fields(
454    agent_setting: Option<String>,
455    _agent_name: Option<String>,
456    _agent_color: Option<String>,
457) -> Option<AgentRestoreInfo> {
458    let agent_setting = agent_setting?;
459
460    Some(AgentRestoreInfo {
461        agent_type: agent_setting,
462        agent_name: _agent_name,
463        agent_color: normalize_agent_color(_agent_color.as_deref()),
464        model_override: None,
465    })
466}
467
468/// Compute standalone agent context from name and color.
469/// Mirrors TypeScript's `computeStandaloneAgentContext`.
470fn compute_standalone_agent_context(
471    agent_name: Option<&str>,
472    agent_color: Option<&str>,
473) -> Option<StandaloneAgentContext> {
474    if agent_name.is_none() && agent_color.is_none() {
475        return None;
476    }
477    Some(StandaloneAgentContext {
478        name: agent_name.unwrap_or("").to_string(),
479        color: normalize_agent_color(agent_color),
480    })
481}
482
483/// Normalize agent color: "default" maps to None.
484fn normalize_agent_color(color: Option<&str>) -> Option<String> {
485    match color {
486        Some("default") => None,
487        Some(c) => Some(c.to_string()),
488        None => None,
489    }
490}
491
492// ---------------------------------------------------------------------------
493// Consistency checks
494// ---------------------------------------------------------------------------
495
496/// Check whether a session can be safely resumed.
497///
498/// Validates:
499/// 1. The session directory and transcript file exist.
500/// 2. The transcript contains at least one entry.
501/// 3. The session metadata is well-formed.
502/// 4. If `parent_session_id` is provided, that parent session also exists.
503///
504/// # Arguments
505///
506/// * `session_id` - The session to check.
507/// * `parent_session_id` - Optional parent session ID to validate.
508///
509/// # Returns
510///
511/// A `ConsistencyCheck` with the result.
512pub async fn check_resume_consistency(
513    session_id: &str,
514    parent_session_id: Option<&str>,
515) -> ConsistencyCheck {
516    let mut issues = Vec::new();
517
518    // Check session directory exists
519    let session_dir = get_session_path(session_id);
520    if !session_dir.exists() {
521        return ConsistencyCheck {
522            can_resume: false,
523            issues: vec![format!(
524                "Session directory does not exist: {:?}",
525                session_dir
526            )],
527        };
528    }
529
530    // Check transcript file exists
531    let jsonl_path = get_jsonl_path(session_id);
532    if !jsonl_path.exists() {
533        issues.push(format!(
534            "Transcript file does not exist: {:?}",
535            jsonl_path
536        ));
537    } else {
538        // Check transcript is not empty
539        match tokio::fs::read_to_string(&jsonl_path).await {
540            Ok(content) => {
541                let line_count = content.lines().filter(|l| !l.trim().is_empty()).count();
542                if line_count == 0 {
543                    issues.push("Transcript file is empty, no entries to resume.".to_string());
544                }
545            }
546            Err(e) => issues.push(format!("Failed to read transcript: {}", e)),
547        }
548    }
549
550    // Check parent session if specified
551    if let Some(parent_id) = parent_session_id {
552        let parent_dir = get_session_path(parent_id);
553        if !parent_dir.exists() {
554            issues.push(format!("Parent session '{}' directory not found: {:?}", parent_id, parent_dir));
555        } else {
556            let parent_jsonl = get_jsonl_path(parent_id);
557            if !parent_jsonl.exists() {
558                issues.push(format!(
559                    "Parent session '{}' has no transcript file: {:?}",
560                    parent_id, parent_jsonl
561                ));
562            }
563        }
564    }
565
566    // Check working directory matches
567    if let Some(cwd) = std::env::var("AI_CODE_CWD").ok() {
568        let current = std::env::current_dir().unwrap_or_default();
569        if cwd != current.to_string_lossy() {
570            issues.push(format!(
571                "Session CWD '{}' differs from current directory '{}'",
572                cwd,
573                current.to_string_lossy()
574            ));
575        }
576    }
577
578    ConsistencyCheck {
579        can_resume: issues.is_empty(),
580        issues,
581    }
582}
583
584/// Check resume consistency and return errors as a String.
585///
586/// Convenience wrapper that returns `Err(String)` on failure for
587/// ergonomic use in error-handling code.
588///
589/// # Arguments
590///
591/// * `session_id` - The session to check.
592/// * `parent_session_id` - Optional parent session ID.
593///
594/// # Returns
595///
596/// `Ok(())` if the session can be safely resumed, `Err(String)` with
597/// a joined list of issues otherwise.
598pub async fn check_resume_consistency_err(
599    session_id: &str,
600    parent_session_id: Option<&str>,
601) -> Result<(), String> {
602    let check = check_resume_consistency(session_id, parent_session_id).await;
603    if check.can_resume {
604        Ok(())
605    } else {
606        Err(check.issues.join("\n"))
607    }
608}
609
610// ---------------------------------------------------------------------------
611// High-level resume handler
612// ---------------------------------------------------------------------------
613
614/// Handle a full session resume: load, restore state, match mode, return result.
615///
616/// This is the top-level orchestration function for session resume. It:
617/// 1. Loads and parses the session transcript
618/// 2. Extracts all state (messages, file history, attribution, etc.)
619/// 3. Matches coordinator mode to the resumed session
620/// 4. Extracts TODO items
621/// 5. Restores agent information
622/// 6. Validates session metadata
623///
624/// # Arguments
625///
626/// * `session_id` - The session identifier to resume.
627///
628/// # Returns
629///
630/// A `SessionRestoreResult` with all restored state, including a coordinator
631/// mode warning message if the mode was switched.
632pub async fn handle_session_resume(session_id: &str) -> Result<SessionRestoreResult, AgentError> {
633    // Run consistency check
634    let check = check_resume_consistency(session_id, None).await;
635    if !check.can_resume {
636        return Err(AgentError::Session(format!(
637            "Resume consistency check failed: {}",
638            check.issues.join("; ")
639        )));
640    }
641
642    // Restore from log
643    let mut result = restore_session_from_log(session_id).await?;
644
645    // Match coordinator mode to the resumed session
646    if let Some(ref mode) = result.mode {
647        if let Some(warning) = match_session_mode(Some(mode)) {
648            // Append the mode-switch warning as a system message
649            result.messages.push(create_system_message(&warning));
650        }
651    }
652
653    // Log restoration summary
654    log::info!(
655        "Session '{}' restored: {} messages, {} file history snapshots, {} attribution snapshots, {} context collapse commits, {} todo items",
656        session_id,
657        result.messages.len(),
658        result.file_history_snapshots.len(),
659        result.attribution_snapshots.len(),
660        result.context_collapse_commits.len(),
661        result.todo_items.len(),
662    );
663
664    Ok(result)
665}
666
667// ---------------------------------------------------------------------------
668// State restoration helpers
669// ---------------------------------------------------------------------------
670
671/// Restore file history state from snapshots.
672///
673/// Applies the last file history snapshot to rebuild the file history
674/// tracking state. Mirrors TypeScript's `fileHistoryRestoreStateFromLog`.
675///
676/// # Arguments
677///
678/// * `snapshots` - File history snapshots extracted from the transcript.
679///
680/// # Returns
681///
682/// The latest merged file history state, or `None` if no snapshots were provided.
683pub fn restore_file_history_state(
684    snapshots: &[FileHistorySnapshot],
685) -> Option<FileHistorySnapshot> {
686    if snapshots.is_empty() {
687        return None;
688    }
689
690    // Merge all snapshots: later snapshots override earlier ones.
691    let mut merged = FileHistorySnapshot::new();
692    for snapshot in snapshots {
693        for (key, value) in snapshot {
694            merged.insert(key.clone(), value.clone());
695        }
696    }
697    Some(merged)
698}
699
700/// Restore attribution state from snapshots.
701///
702/// Applies the last attribution snapshot to rebuild the commit attribution
703/// tracking state. Mirrors TypeScript's `attributionRestoreStateFromLog`.
704///
705/// # Arguments
706///
707/// * `snapshots` - Attribution snapshots extracted from the transcript.
708///
709/// # Returns
710///
711/// The latest attribution snapshot, or `None` if no snapshots were provided.
712pub fn restore_attribution_state(
713    snapshots: &[AttributionSnapshotMessage],
714) -> Option<AttributionSnapshotMessage> {
715    snapshots.last().cloned()
716}
717
718/// Restore worktree session state.
719///
720/// Checks whether the worktree path from the session transcript still
721/// exists. If it does, returns the worktree state so the caller can cd
722/// back into it. If the directory is gone, returns `None` to indicate
723/// the worktree should be considered exited.
724///
725/// # Arguments
726///
727/// * `worktree_session` - The persisted worktree session from the transcript.
728///
729/// # Returns
730///
731/// `Some(PersistedWorktreeSession)` if the worktree directory exists,
732/// `None` if it has been removed.
733pub fn restore_worktree_state(
734    worktree_session: Option<PersistedWorktreeSession>,
735) -> Option<PersistedWorktreeSession> {
736    let ws = worktree_session?;
737
738    // TOCTOU-safe check: if the directory doesn't exist, treat as exited
739    let path = PathBuf::from(&ws.worktree_path);
740    if !path.is_dir() {
741        log::warn!(
742            "Worktree directory no longer exists: {:?}. Treating as exited.",
743            path
744        );
745        return None;
746    }
747
748    Some(ws)
749}
750
751/// Create a system message for coordinator mode switch notifications.
752fn create_system_message(content: &str) -> Message {
753    Message {
754        role: MessageRole::System,
755        content: content.to_string(),
756        attachments: None,
757        tool_call_id: None,
758        tool_calls: None,
759        is_error: None,
760        is_meta: Some(true),
761        is_api_error_message: None,
762        error_details: None,
763        uuid: None,
764    }
765}
766
767/// Write a mode entry to the session transcript.
768///
769/// Persists the current coordinator mode so future resumes know what mode
770/// this session was in.
771///
772/// # Arguments
773///
774/// * `session_id` - The session to write to.
775pub async fn save_mode_to_session(session_id: &str) {
776    let mode = if is_coordinator_mode() {
777        "coordinator"
778    } else {
779        "normal"
780    };
781    let entry = SessionEntry {
782        timestamp: Some(chrono::Utc::now().to_rfc3339()),
783        entry_type: Some("mode".to_string()),
784        data: Some(serde_json::json!({
785            "mode": mode,
786            "sessionId": session_id,
787        })),
788    };
789    if let Err(e) = crate::session::append_session_entry(session_id, &entry).await {
790        log::warn!("Failed to save mode entry: {}", e);
791    }
792}
793
794/// Apply context-collapse restoration from a restore result.
795///
796/// This convenience function calls the context-collapse persist module with
797/// the data from a `SessionRestoreResult`. In the current SDK build, the
798/// persist module is a no-op stub. When context-collapse is fully
799/// implemented, this wires through to the real store.
800///
801/// # Arguments
802///
803/// * `result` - The session restore result containing collapse data.
804pub fn apply_context_collapse_restore(result: &SessionRestoreResult) {
805    // Wire through to the context-collapse persist module.
806    // Currently a no-op in the SDK; the real implementation lives in
807    // TypeScript and will be ported when context-collapse is enabled.
808    let _ = &result.context_collapse_commits;
809    let _ = &result.context_collapse_snapshot;
810}
811
812/// Get the sessions directory path (re-export for convenience).
813pub fn session_restore_dir() -> PathBuf {
814    get_sessions_dir()
815}
816
817// ---------------------------------------------------------------------------
818// Tests
819// ---------------------------------------------------------------------------
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    fn make_message_entry(role: &str, content: &str) -> SessionEntry {
826        SessionEntry {
827            timestamp: Some(chrono::Utc::now().to_rfc3339()),
828            entry_type: Some("message".to_string()),
829            data: Some(serde_json::json!({
830                "role": role,
831                "content": content,
832            })),
833        }
834    }
835
836    fn make_assistant_with_tool_call(tool_name: &str, todos: Vec<serde_json::Value>) -> SessionEntry {
837        SessionEntry {
838            timestamp: Some(chrono::Utc::now().to_rfc3339()),
839            entry_type: Some("message".to_string()),
840            data: Some(serde_json::json!({
841                "role": "assistant",
842                "content": "",
843                "tool_calls": [{
844                    "id": "toolu-123",
845                    "type": "function",
846                    "name": tool_name,
847                    "input": {
848                        "todos": todos,
849                    },
850                }],
851            })),
852        }
853    }
854
855    fn make_agent_setting_entry(setting: &str) -> SessionEntry {
856        SessionEntry {
857            timestamp: Some(chrono::Utc::now().to_rfc3339()),
858            entry_type: Some("agent-setting".to_string()),
859            data: Some(serde_json::json!({
860                "agentSetting": setting,
861                "sessionId": "test-session",
862            })),
863        }
864    }
865
866    fn make_agent_name_entry(name: &str) -> SessionEntry {
867        SessionEntry {
868            timestamp: Some(chrono::Utc::now().to_rfc3339()),
869            entry_type: Some("agent-name".to_string()),
870            data: Some(serde_json::json!({
871                "agentName": name,
872                "sessionId": "test-session",
873            })),
874        }
875    }
876
877    fn make_agent_color_entry(color: &str) -> SessionEntry {
878        SessionEntry {
879            timestamp: Some(chrono::Utc::now().to_rfc3339()),
880            entry_type: Some("agent-color".to_string()),
881            data: Some(serde_json::json!({
882                "agentColor": color,
883                "sessionId": "test-session",
884            })),
885        }
886    }
887
888    // --- extract_todo_from_transcript tests ---
889
890    #[test]
891    fn test_extract_todo_finds_last_todo_write() {
892        let entries = vec![
893            make_message_entry("user", "hello"),
894            make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
895                serde_json::json!({"content": "first task", "id": "1", "done": false}),
896                serde_json::json!({"content": "second task", "id": "2", "done": false}),
897            ]),
898            make_message_entry("user", "done with first"),
899            make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
900                serde_json::json!({"content": "first task", "id": "1", "done": true}),
901                serde_json::json!({"content": "third task", "id": "3", "done": false}),
902            ]),
903        ];
904        let todos = extract_todo_from_transcript(&entries);
905        // Should find the LAST TodoWrite call
906        assert_eq!(todos.len(), 2);
907        assert_eq!(todos[0], "first task");
908        assert_eq!(todos[1], "third task");
909    }
910
911    #[test]
912    fn test_extract_todo_empty_transcript() {
913        let entries: Vec<SessionEntry> = vec![];
914        let todos = extract_todo_from_transcript(&entries);
915        assert!(todos.is_empty());
916    }
917
918    #[test]
919    fn test_extract_todo_no_todo_write() {
920        let entries = vec![
921            make_message_entry("user", "hello"),
922            make_message_entry("assistant", "hi there"),
923            make_assistant_with_tool_call("Read", vec![]),
924        ];
925        let todos = extract_todo_from_transcript(&entries);
926        assert!(todos.is_empty());
927    }
928
929    #[test]
930    fn test_extract_todo_plain_string_items() {
931        let entries = vec![make_assistant_with_tool_call(TODO_WRITE_TOOL_NAME, vec![
932            serde_json::json!("just a task"),
933            serde_json::json!("another task"),
934        ])];
935        // Plain strings in todos array are handled by the as_str() fallback
936        let todos = extract_todo_from_transcript(&entries);
937        assert_eq!(todos.len(), 2);
938        assert_eq!(todos[0], "just a task");
939        assert_eq!(todos[1], "another task");
940    }
941
942    // --- restore_agent_from_session tests ---
943
944    #[test]
945    fn test_restore_agent_finds_all_fields() {
946        let entries = vec![
947            make_message_entry("user", "hello"),
948            make_agent_setting_entry("reviewer"),
949            make_agent_name_entry("Code Reviewer"),
950            make_agent_color_entry("blue"),
951        ];
952        let info = restore_agent_from_session(&entries);
953        assert!(info.is_some());
954        let info = info.unwrap();
955        assert_eq!(info.agent_type, "reviewer");
956        assert_eq!(info.agent_color, Some("blue".to_string()));
957    }
958
959    #[test]
960    fn test_restore_agent_no_setting() {
961        let entries = vec![
962            make_agent_name_entry("Some Agent"),
963            make_agent_color_entry("red"),
964        ];
965        // Without agent-setting, should return None
966        let info = restore_agent_from_session(&entries);
967        assert!(info.is_none());
968    }
969
970    #[test]
971    fn test_restore_agent_default_color_normalized() {
972        let entries = vec![
973            make_agent_setting_entry("worker"),
974            make_agent_color_entry("default"),
975        ];
976        let info = restore_agent_from_session(&entries);
977        assert!(info.is_some());
978        let info = info.unwrap();
979        assert_eq!(info.agent_type, "worker");
980        // "default" color should be normalized to None
981        assert_eq!(info.agent_color, None);
982    }
983
984    // --- restore_file_history_state tests ---
985
986    #[test]
987    fn test_restore_file_history_empty() {
988        let snapshots: Vec<FileHistorySnapshot> = vec![];
989        let result = restore_file_history_state(&snapshots);
990        assert!(result.is_none());
991    }
992
993    #[test]
994    fn test_restore_file_history_merges() {
995        let mut s1 = FileHistorySnapshot::new();
996        s1.insert("file_a".to_string(), serde_json::json!({"hash": "abc"}));
997
998        let mut s2 = FileHistorySnapshot::new();
999        s2.insert("file_b".to_string(), serde_json::json!({"hash": "def"}));
1000        s2.insert("file_a".to_string(), serde_json::json!({"hash": "abc2"}));
1001
1002        let result = restore_file_history_state(&[s1, s2]);
1003        assert!(result.is_some());
1004        let merged = result.unwrap();
1005        assert_eq!(merged.len(), 2);
1006        // file_a should be overridden by s2
1007        assert_eq!(merged["file_a"], serde_json::json!({"hash": "abc2"}));
1008        assert_eq!(merged["file_b"], serde_json::json!({"hash": "def"}));
1009    }
1010
1011    // --- restore_attribution_state tests ---
1012
1013    #[test]
1014    fn test_restore_attribution_empty() {
1015        let snapshots: Vec<AttributionSnapshotMessage> = vec![];
1016        let result = restore_attribution_state(&snapshots);
1017        assert!(result.is_none());
1018    }
1019
1020    #[test]
1021    fn test_restore_attribution_returns_last() {
1022        let s1 = AttributionSnapshotMessage {
1023            message_type: "attribution-snapshot".to_string(),
1024            message_id: uuid::Uuid::new_v4(),
1025            surface: "edit".to_string(),
1026            file_states: HashMap::new(),
1027            prompt_count: Some(1),
1028            prompt_count_at_last_commit: None,
1029            permission_prompt_count: None,
1030            permission_prompt_count_at_last_commit: None,
1031            escape_count: None,
1032            escape_count_at_last_commit: None,
1033        };
1034        let s2 = AttributionSnapshotMessage {
1035            message_type: "attribution-snapshot".to_string(),
1036            message_id: uuid::Uuid::new_v4(),
1037            surface: "edit".to_string(),
1038            file_states: HashMap::new(),
1039            prompt_count: Some(5),
1040            prompt_count_at_last_commit: None,
1041            permission_prompt_count: None,
1042            permission_prompt_count_at_last_commit: None,
1043            escape_count: None,
1044            escape_count_at_last_commit: None,
1045        };
1046        let result = restore_attribution_state(&[s1, s2]);
1047        assert!(result.is_some());
1048        assert_eq!(result.unwrap().prompt_count, Some(5));
1049    }
1050
1051    // --- restore_worktree_state tests ---
1052
1053    #[test]
1054    fn test_restore_worktree_none() {
1055        let result = restore_worktree_state(None);
1056        assert!(result.is_none());
1057    }
1058
1059    #[test]
1060    fn test_restore_worktree_missing_dir() {
1061        let ws = PersistedWorktreeSession {
1062            original_cwd: "/tmp".to_string(),
1063            worktree_path: "/tmp/nonexistent-worktree-path-12345".to_string(),
1064            worktree_name: "test".to_string(),
1065            worktree_branch: None,
1066            original_branch: None,
1067            original_head_commit: None,
1068            session_id: "test-session".to_string(),
1069            tmux_session_name: None,
1070            hook_based: None,
1071        };
1072        // Directory doesn't exist, should return None
1073        let result = restore_worktree_state(Some(ws));
1074        assert!(result.is_none());
1075    }
1076
1077    #[test]
1078    fn test_restore_worktree_existing_dir() {
1079        let ws = PersistedWorktreeSession {
1080            original_cwd: "/tmp".to_string(),
1081            worktree_path: "/tmp".to_string(),
1082            worktree_name: "test".to_string(),
1083            worktree_branch: None,
1084            original_branch: None,
1085            original_head_commit: None,
1086            session_id: "test-session".to_string(),
1087            tmux_session_name: None,
1088            hook_based: None,
1089        };
1090        // /tmp always exists
1091        let result = restore_worktree_state(Some(ws));
1092        assert!(result.is_some());
1093    }
1094
1095    // --- normalize_agent_color tests ---
1096
1097    #[test]
1098    fn test_normalize_agent_color() {
1099        assert_eq!(normalize_agent_color(Some("default")), None);
1100        assert_eq!(normalize_agent_color(Some("blue")), Some("blue".to_string()));
1101        assert_eq!(normalize_agent_color(None), None);
1102    }
1103
1104    // --- compute_standalone_agent_context tests ---
1105
1106    #[test]
1107    fn test_compute_standalone_agent_context_both_none() {
1108        let result = compute_standalone_agent_context(None, None);
1109        assert!(result.is_none());
1110    }
1111
1112    #[test]
1113    fn test_compute_standalone_agent_context_name_only() {
1114        let result = compute_standalone_agent_context(Some("Reviewer"), None);
1115        assert!(result.is_some());
1116        assert_eq!(result.unwrap().name, "Reviewer");
1117    }
1118
1119    #[test]
1120    fn test_compute_standalone_agent_context_with_default_color() {
1121        let result = compute_standalone_agent_context(Some("Agent"), Some("default"));
1122        assert!(result.is_some());
1123        let ctx = result.unwrap();
1124        assert_eq!(ctx.name, "Agent");
1125        assert_eq!(ctx.color, None);
1126    }
1127
1128    // --- create_system_message tests ---
1129
1130    #[test]
1131    fn test_create_system_message() {
1132        let msg = create_system_message("Mode switched");
1133        assert_eq!(msg.role, MessageRole::System);
1134        assert_eq!(msg.content, "Mode switched");
1135        assert!(msg.is_meta == Some(true));
1136    }
1137
1138    // --- check_resume_consistency tests ---
1139
1140    #[tokio::test]
1141    async fn test_check_resume_consistency_nonexistent() {
1142        let check = check_resume_consistency("nonexistent-session-12345", None).await;
1143        assert!(!check.can_resume);
1144        assert!(!check.issues.is_empty());
1145    }
1146
1147    #[tokio::test]
1148    async fn test_check_resume_consistency_valid_session() {
1149        crate::tests::common::clear_all_test_state();
1150        let session_id = format!("consistency-test-{}", uuid::Uuid::new_v4());
1151
1152        // Create a session with some entries
1153        let msg = crate::session::SessionEntry::message(&crate::types::api_types::Message {
1154            role: crate::types::api_types::MessageRole::User,
1155            content: "hello".to_string(),
1156            ..Default::default()
1157        });
1158        crate::session::append_session_entry(&session_id, &msg).await.unwrap();
1159
1160        let check = check_resume_consistency(&session_id, None).await;
1161        assert!(check.can_resume);
1162        assert!(check.issues.is_empty());
1163
1164        // Cleanup
1165        let _ = tokio::fs::remove_dir_all(crate::session::get_session_path(&session_id)).await;
1166    }
1167
1168    #[tokio::test]
1169    async fn test_check_resume_consistency_with_missing_parent() {
1170        crate::tests::common::clear_all_test_state();
1171        let session_id = format!("consistency-parent-test-{}", uuid::Uuid::new_v4());
1172
1173        // Create the session
1174        let msg = crate::session::SessionEntry::message(&crate::types::api_types::Message {
1175            role: crate::types::api_types::MessageRole::User,
1176            content: "hello".to_string(),
1177            ..Default::default()
1178        });
1179        crate::session::append_session_entry(&session_id, &msg).await.unwrap();
1180
1181        // Check with a nonexistent parent
1182        let check = check_resume_consistency(&session_id, Some("missing-parent-session")).await;
1183        assert!(!check.can_resume);
1184        assert!(check.issues.iter().any(|i| i.contains("Parent session")));
1185
1186        // Cleanup
1187        let _ = tokio::fs::remove_dir_all(crate::session::get_session_path(&session_id)).await;
1188    }
1189
1190    // --- SessionRestoreResult tests ---
1191
1192    #[test]
1193    fn test_session_restore_result_debug() {
1194        let result = SessionRestoreResult {
1195            messages: vec![],
1196            file_history_snapshots: vec![],
1197            attribution_snapshots: vec![],
1198            context_collapse_commits: vec![],
1199            context_collapse_snapshot: None,
1200            todo_items: vec!["test".to_string()],
1201            agent_info: None,
1202            standalone_agent_context: None,
1203            metadata: None,
1204            mode: None,
1205            worktree_session: None,
1206            custom_title: None,
1207            tag: None,
1208            skipped_count: 0,
1209        };
1210        // Just verify Debug works
1211        let _ = format!("{:?}", result);
1212    }
1213
1214    // --- parse_todos_from_tool_input tests ---
1215
1216    #[test]
1217    fn test_parse_todos_from_tool_input() {
1218        let tool_call = serde_json::json!({
1219            "name": "TodoWrite",
1220            "input": {
1221                "todos": [
1222                    {"content": "task one", "id": "1", "done": false},
1223                    {"content": "task two", "id": "2", "done": true},
1224                ]
1225            }
1226        });
1227        let todos = parse_todos_from_tool_input(&tool_call);
1228        assert_eq!(todos.len(), 2);
1229        assert_eq!(todos[0], "task one");
1230        assert_eq!(todos[1], "task two");
1231    }
1232
1233    #[test]
1234    fn test_parse_todos_from_tool_input_empty() {
1235        let tool_call = serde_json::json!({
1236            "name": "TodoWrite",
1237            "input": {
1238                "todos": []
1239            }
1240        });
1241        let todos = parse_todos_from_tool_input(&tool_call);
1242        assert!(todos.is_empty());
1243    }
1244
1245    #[test]
1246    fn test_parse_todos_from_tool_input_no_input() {
1247        let tool_call = serde_json::json!({
1248            "name": "Read",
1249        });
1250        let todos = parse_todos_from_tool_input(&tool_call);
1251        assert!(todos.is_empty());
1252    }
1253}