From 8926775acb1ee0a6844e178fdd58eac57d1bdffa Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Sun, 11 Jan 2026 05:30:58 +0800 Subject: [PATCH] Add session continuation symlink fix and /resume command Fix session detection: - Add save_session_continuation() calls at all session exit points - Sessions now properly create .g3/session symlink for resume detection - Fixes issue where g3 wasn't offering to resume previous sessions Add /resume command: - New list_sessions_for_directory() to scan available sessions - New switch_to_session() method to safely switch between sessions - Shows numbered list with timestamps, context %, and TODO status - Saves current session before switching (can be resumed later) - Restores full context if <80% used, otherwise uses summary - Machine mode supports /resume and /resume Documentation: - Add /clear and /resume to CONTROL_COMMANDS.md - Update /help output with new commands --- crates/g3-cli/src/lib.rs | 140 ++++++++++++++++++++- crates/g3-core/src/lib.rs | 38 +++++- crates/g3-core/src/session_continuation.rs | 65 ++++++++++ docs/CONTROL_COMMANDS.md | 72 +++++++++++ 4 files changed, 313 insertions(+), 2 deletions(-) diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index ccbf808..9dd8875 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -858,6 +858,9 @@ async fn run_agent_mode( let _result = agent.execute_task(final_task, None, true).await?; + // Save session continuation for resume capability + agent.save_session_continuation(None); + // Don't print completion message for scout agent - it needs the last line // to be the report file path for the research tool to read if agent_name != "scout" { @@ -1261,6 +1264,9 @@ async fn run_autonomous_machine( println!("END_AGENT_RESPONSE"); println!("TASK_END"); + // Save session continuation for resume capability + agent.save_session_continuation(Some(result.response.clone())); + println!("AUTONOMOUS_MODE_ENDED"); Ok(()) } @@ -1299,6 +1305,8 @@ async fn run_with_console_mode( ) .await?; output.print_smart(&result.response); + // Save session continuation for resume capability + agent.save_session_continuation(Some(result.response.clone())); } else { // Interactive mode (default) run_interactive( @@ -1347,6 +1355,8 @@ async fn run_with_machine_mode( println!("AGENT_RESPONSE:"); println!("{}", result.response); println!("END_AGENT_RESPONSE"); + // Save session continuation for resume capability + agent.save_session_continuation(Some(result.response.clone())); } else { // Interactive mode run_interactive_machine(agent, cli.show_prompt, cli.show_code).await?; @@ -1700,6 +1710,7 @@ async fn run_interactive( output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)"); output.print(" /skinnify - Trigger full context thinning (like /thinnify but for entire context, not just first third)"); output.print(" /clear - Clear session and start fresh (discards continuation artifacts)"); + output.print(" /resume - List and switch to a previous session"); output.print( " /readme - Reload README.md and AGENTS.md from disk", ); @@ -1762,6 +1773,72 @@ async fn run_interactive( output.print(&stats); continue; } + "/resume" => { + output.print("๐Ÿ“‹ Scanning for available sessions..."); + + match g3_core::list_sessions_for_directory() { + Ok(sessions) => { + if sessions.is_empty() { + output.print("No sessions found for this directory."); + continue; + } + + // Get current session ID to mark it + let current_session_id = agent.get_session_id().map(|s| s.to_string()); + + output.print(""); + output.print("Available sessions:"); + for (i, session) in sessions.iter().enumerate() { + let time_str = g3_core::format_session_time(&session.created_at); + let context_str = format!("{:.0}%", session.context_percentage); + let current_marker = if current_session_id.as_deref() == Some(&session.session_id) { + " (current)" + } else { + "" + }; + let todo_marker = if session.has_incomplete_todos() { " ๐Ÿ“" } else { "" }; + + // Truncate session ID for display + let display_id = if session.session_id.len() > 40 { + format!("{}...", &session.session_id[..40]) + } else { + session.session_id.clone() + }; + + output.print(&format!( + " {}. [{}] {} ({}){}{}", + i + 1, time_str, display_id, context_str, todo_marker, current_marker + )); + } + output.print(""); + output.print("Enter session number to resume (or press Enter to cancel):"); + + // Read user selection + if let Ok(selection) = rl.readline("> ") { + let selection = selection.trim(); + if selection.is_empty() { + output.print("Resume cancelled."); + } else if let Ok(num) = selection.parse::() { + if num >= 1 && num <= sessions.len() { + let selected = &sessions[num - 1]; + output.print(&format!("๐Ÿ”„ Switching to session: {}", selected.session_id)); + match agent.switch_to_session(selected) { + Ok(true) => output.print("โœ… Full context restored from session."), + Ok(false) => output.print("โœ… Session restored from summary."), + Err(e) => output.print(&format!("โŒ Error restoring session: {}", e)), + } + } else { + output.print("Invalid selection."); + } + } else { + output.print("Invalid input. Please enter a number."); + } + } + } + Err(e) => output.print(&format!("โŒ Error listing sessions: {}", e)), + } + continue; + } _ => { output.print(&format!( "โŒ Unknown command: {}. Type /help for available commands.", @@ -1804,6 +1881,9 @@ async fn run_interactive( let _ = rl.save_history(history_path); } + // Save session continuation for resume capability + agent.save_session_continuation(None); + output.print("๐Ÿ‘‹ Goodbye!"); Ok(()) } @@ -1986,10 +2066,62 @@ async fn run_interactive_machine( } "/help" => { println!("COMMAND: help"); - println!("AVAILABLE_COMMANDS: /compact /thinnify /skinnify /clear /readme /stats /help"); + println!("AVAILABLE_COMMANDS: /compact /thinnify /skinnify /clear /resume /readme /stats /help"); + continue; + } + "/resume" => { + println!("COMMAND: resume"); + match g3_core::list_sessions_for_directory() { + Ok(sessions) => { + if sessions.is_empty() { + println!("RESULT: No sessions found"); + continue; + } + + println!("SESSIONS_START"); + for (i, session) in sessions.iter().enumerate() { + let time_str = g3_core::format_session_time(&session.created_at); + let has_todos = if session.has_incomplete_todos() { "true" } else { "false" }; + println!( + "SESSION: {} | {} | {} | {:.0}% | {}", + i + 1, + session.session_id, + time_str, + session.context_percentage, + has_todos + ); + } + println!("SESSIONS_END"); + println!("HINT: Use /resume to switch to a session"); + } + Err(e) => println!("ERROR: {}", e), + } continue; } _ => { + // Check for /resume pattern + if input.starts_with("/resume ") { + let num_str = input.strip_prefix("/resume ").unwrap().trim(); + if let Ok(num) = num_str.parse::() { + println!("COMMAND: resume {}", num); + match g3_core::list_sessions_for_directory() { + Ok(sessions) => { + if num >= 1 && num <= sessions.len() { + let selected = &sessions[num - 1]; + match agent.switch_to_session(selected) { + Ok(true) => println!("RESULT: Full context restored from session {}", selected.session_id), + Ok(false) => println!("RESULT: Session {} restored from summary", selected.session_id), + Err(e) => println!("ERROR: {}", e), + } + } else { + println!("ERROR: Invalid session number"); + } + } + Err(e) => println!("ERROR: {}", e), + } + continue; + } + } println!("ERROR: Unknown command: {}", input); continue; } @@ -2015,6 +2147,9 @@ async fn run_interactive_machine( let _ = rl.save_history(history_path); } + // Save session continuation for resume capability + agent.save_session_continuation(None); + println!("INTERACTIVE_MODE_ENDED"); Ok(()) } @@ -2938,5 +3073,8 @@ Remember: Be clear in your review and concise in your feedback. APPROVE iff the )); } + // Save session continuation for resume capability + agent.save_session_continuation(None); + Ok(()) } diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index d5bdb7f..ddd61cd 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -23,7 +23,7 @@ pub mod webdriver_session; pub use task_result::TaskResult; pub use retry::{RetryConfig, RetryResult, execute_with_retry, retry_operation}; pub use feedback_extraction::{ExtractedFeedback, FeedbackSource, FeedbackExtractionConfig, extract_coach_feedback}; -pub use session_continuation::{SessionContinuation, load_continuation, save_continuation, clear_continuation, has_valid_continuation, get_session_dir, load_context_from_session_log, find_incomplete_agent_session}; +pub use session_continuation::{SessionContinuation, load_continuation, save_continuation, clear_continuation, has_valid_continuation, get_session_dir, load_context_from_session_log, find_incomplete_agent_session, list_sessions_for_directory, format_session_time}; // Re-export context window types pub use context_window::{ContextWindow, ThinScope}; @@ -1528,6 +1528,42 @@ impl Agent { Ok(false) } + /// Switch to a different session, saving the current one first. + /// This discards the current in-memory state and loads the new session. + pub fn switch_to_session( + &mut self, + continuation: &crate::session_continuation::SessionContinuation, + ) -> Result { + // Save current session first (so it can be resumed later) + self.save_session_continuation(None); + + // Reset session-specific metrics + self.thinning_events.clear(); + self.compaction_events.clear(); + self.first_token_times.clear(); + self.tool_call_metrics.clear(); + self.tool_call_count = 0; + self.pending_90_compaction = false; + + // Update session ID to the new session + self.session_id = Some(continuation.session_id.clone()); + + // Update agent mode info from continuation + self.is_agent_mode = continuation.is_agent_mode; + self.agent_name = continuation.agent_name.clone(); + + // Load TODO content from the new session if available + if let Some(ref todo) = continuation.todo_snapshot { + // Use blocking write since we're in a sync context + if let Ok(mut guard) = self.todo_content.try_write() { + *guard = todo.clone(); + } + } + + // Restore context from the continuation + self.restore_from_continuation(continuation) + } + async fn stream_completion( &mut self, request: CompletionRequest, diff --git a/crates/g3-core/src/session_continuation.rs b/crates/g3-core/src/session_continuation.rs index 37317ed..ae0f1c1 100644 --- a/crates/g3-core/src/session_continuation.rs +++ b/crates/g3-core/src/session_continuation.rs @@ -375,6 +375,71 @@ pub fn find_incomplete_agent_session(agent_name: &str) -> Result Result> { + let sessions_dir = get_sessions_dir(); + + if !sessions_dir.exists() { + debug!("Sessions directory does not exist: {:?}", sessions_dir); + return Ok(Vec::new()); + } + + let current_dir = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let mut sessions: Vec = Vec::new(); + + // Scan all session directories + for entry in std::fs::read_dir(&sessions_dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + // Check for latest.json in this session directory + let latest_path = path.join(CONTINUATION_FILENAME); + if !latest_path.exists() { + continue; + } + + // Try to load the continuation + let json = match std::fs::read_to_string(&latest_path) { + Ok(j) => j, + Err(_) => continue, + }; + + let continuation: SessionContinuation = match serde_json::from_str(&json) { + Ok(c) => c, + Err(_) => continue, // Skip sessions with old format + }; + + // Only include sessions from the current working directory + if continuation.working_directory == current_dir { + sessions.push(continuation); + } + } + + // Sort by created_at descending (most recent first) + sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + Ok(sessions) +} + +/// Format a session's created_at timestamp for display +pub fn format_session_time(created_at: &str) -> String { + match chrono::DateTime::parse_from_rfc3339(created_at) { + Ok(dt) => { + let local: chrono::DateTime = dt.into(); + local.format("%Y-%m-%d %H:%M").to_string() + } + Err(_) => created_at.to_string(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/CONTROL_COMMANDS.md b/docs/CONTROL_COMMANDS.md index 5ec4f33..37b33b9 100644 --- a/docs/CONTROL_COMMANDS.md +++ b/docs/CONTROL_COMMANDS.md @@ -14,6 +14,8 @@ Control commands are special commands you can use during an interactive G3 sessi | `/compact` | Manually trigger conversation compaction | | `/thinnify` | Replace large tool results with file references (first third) | | `/skinnify` | Full context thinning (entire context window) | +| `/clear` | Clear session and start fresh | +| `/resume` | List and switch to a previous session | | `/readme` | Reload README.md and AGENTS.md from disk | | `/stats` | Show detailed context and performance statistics | | `/help` | Display all available control commands | @@ -105,6 +107,74 @@ g3> /skinnify --- +## /clear + +Clear the current session and start fresh. + +**When to use**: +- You want to start a completely new task +- The current context is cluttered or confused +- You want to discard all conversation history + +**What it does**: +1. Clears all conversation history (keeps system prompt) +2. Removes the session continuation symlink +3. Resets context to initial state + +**Example**: +``` +g3> /clear +๐Ÿงน Clearing session... +โœ… Session cleared. Starting fresh. +``` + +**Notes**: +- This is irreversible for the current session +- Previous session data remains in `.g3/sessions/` and can be resumed with `/resume` +- Use when you want a clean slate + +--- + +## /resume + +List available sessions and switch to a previous one. + +**When to use**: +- You want to continue work from a previous session +- You accidentally cleared or lost context +- You want to switch between different tasks/sessions + +**What it does**: +1. Scans `.g3/sessions/` for sessions in the current directory +2. Displays a numbered list with timestamps and context usage +3. Prompts for selection +4. Saves current session before switching +5. Restores the selected session's context + +**Example**: +``` +g3> /resume +๐Ÿ“‹ Scanning for available sessions... + +Available sessions: + 1. [2025-01-11 14:30] implement_auth_feature_abc123 (45%) ๐Ÿ“ + 2. [2025-01-11 10:15] fix_bug_in_parser_def456 (23%) + 3. [2025-01-10 16:45] refactor_database_layer_ghi789 (67%) + +Enter session number to resume (or press Enter to cancel): +> 1 +๐Ÿ”„ Switching to session: implement_auth_feature_abc123 +โœ… Full context restored from session. +``` + +**Notes**: +- Sessions marked with ๐Ÿ“ have incomplete TODO items +- Current session is marked with "(current)" +- Only sessions from the current working directory are shown +- Full context is restored if usage was <80%, otherwise summary is used + +--- + ## /readme Reload README.md and AGENTS.md from disk without restarting. @@ -174,6 +244,8 @@ g3> /help /compact - Summarize conversation to reduce context /thinnify - Replace large tool results with file refs /skinnify - Full context thinning (entire window) + /clear - Clear session and start fresh + /resume - List and switch to a previous session /readme - Reload README.md and AGENTS.md /stats - Show context and performance statistics /help - Show this help message