Fix continuation errors: auto-continue when final_output not called

- Add final_output_called flag to track if LLM properly completed
- Auto-continue with prompt if tools executed but final_output missing
- Remove unused last_action_was_tool and any_text_response variables
- Simplifies previous complex incomplete response detection logic
This commit is contained in:
Dhanji R. Prasanna
2025-12-20 15:32:12 +11:00
parent ba8bd371fc
commit fbf31e5f68
5 changed files with 789 additions and 49 deletions

View File

@@ -5,11 +5,12 @@ You are allergic to cleverness.
MISSION MISSION
Refactor code to: Refactor code to:
- KISS / readability first - KISS / readability first
- aggressively prevent code-path aliasing (multiple “almost equivalent” logic paths that drift over time)
- deduplicate and eliminate near-duplicates - deduplicate and eliminate near-duplicates
- reduce cyclomatic complexity and deep nesting - reduce cyclomatic complexity and deep nesting
- reduce general complexity
- make code act as documentation (names, structure, shape) - make code act as documentation (names, structure, shape)
- increase robustness at boundaries - increase robustness at boundaries
- aggressively prevent code-path aliasing (multiple “almost equivalent” logic paths that drift over time)
You do not add features. You do not add features.
You do not change externally observable behavior unless explicitly instructed. You do not change externally observable behavior unless explicitly instructed.
@@ -27,7 +28,7 @@ TESTING DOCTRINE (NON-NEGOTIABLE)
Purpose: Purpose:
Tests exist to: Tests exist to:
1. Lock behavior during refactors 1. Lock behavior during refactors
2. Buy permission to simplify 2. Simplify mercilessly, but stop short of changing behavior
They are not written to chase coverage metrics. They are not written to chase coverage metrics.
@@ -102,6 +103,7 @@ Prefer:
- isolate side effects from pure logic - isolate side effects from pure logic
- single canonical decision functions - single canonical decision functions
- centralized validation and normalization - centralized validation and normalization
- smaller files (< 1000 lines) mapping to logical units
Avoid speculative abstractions. Avoid speculative abstractions.
@@ -141,6 +143,7 @@ STYLE CONSTRAINTS
- No new dependencies unless asked. - No new dependencies unless asked.
- No architecture for its own sake. - No architecture for its own sake.
- Assume the next reader is tired, busy, and suspicious. - Assume the next reader is tired, busy, and suspicious.
- modular, short, concise, clear > baroque, clever, colocated, "god objects"
# IMPORTANT # IMPORTANT
Do not ask any questions, directly perform the aforementioned actions on the current project Do not ask any questions, directly perform the aforementioned actions on the current project

View File

@@ -1384,6 +1384,54 @@ async fn run_interactive<W: UiWriter>(
) -> Result<()> { ) -> Result<()> {
let output = SimpleOutput::new(); let output = SimpleOutput::new();
// Check for session continuation
if let Ok(Some(continuation)) = g3_core::load_continuation() {
output.print("");
output.print("🔄 Previous session detected!");
output.print(&format!(
" Session: {}",
&continuation.session_id[..continuation.session_id.len().min(20)]
));
output.print(&format!(
" Context: {:.1}% used",
continuation.context_percentage
));
if let Some(ref summary) = continuation.final_output_summary {
let preview: String = summary.chars().take(80).collect();
output.print(&format!(" Last output: {}...", preview));
}
output.print("");
output.print("Resume this session? [Y/n] ");
// Read user input
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" || input == "yes" {
// Resume the session
match agent.restore_from_continuation(&continuation) {
Ok(true) => {
output.print("✅ Full context restored from previous session");
}
Ok(false) => {
output.print("✅ Session resumed with summary (context was > 80%)");
}
Err(e) => {
output.print(&format!("⚠️ Could not restore session: {}", e));
output.print("Starting fresh session instead.");
// Clear the invalid continuation
let _ = g3_core::clear_continuation();
}
}
} else {
// User declined, clear the continuation
output.print("🧹 Starting fresh session...");
let _ = g3_core::clear_continuation();
}
output.print("");
}
output.print(""); output.print("");
output.print("g3 programming agent"); output.print("g3 programming agent");
output.print(" >> what shall we build today?"); output.print(" >> what shall we build today?");
@@ -1527,6 +1575,7 @@ async fn run_interactive<W: UiWriter>(
output.print(" /compact - Trigger auto-summarization (compacts conversation history)"); output.print(" /compact - Trigger auto-summarization (compacts conversation history)");
output.print(" /thinnify - Trigger context thinning (replaces large tool results with file references)"); 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(" /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( output.print(
" /readme - Reload README.md and AGENTS.md from disk", " /readme - Reload README.md and AGENTS.md from disk",
); );
@@ -1564,6 +1613,12 @@ async fn run_interactive<W: UiWriter>(
println!("{}", summary); println!("{}", summary);
continue; continue;
} }
"/clear" => {
output.print("🧹 Clearing session...");
agent.clear_session();
output.print("✅ Session cleared. Starting fresh.");
continue;
}
"/readme" => { "/readme" => {
output.print("📚 Reloading README.md and AGENTS.md..."); output.print("📚 Reloading README.md and AGENTS.md...");
match agent.reload_readme() { match agent.reload_readme() {
@@ -1779,6 +1834,12 @@ async fn run_interactive_machine(
println!("{}", summary); println!("{}", summary);
continue; continue;
} }
"/clear" => {
println!("COMMAND: clear");
agent.clear_session();
println!("RESULT: Session cleared");
continue;
}
"/readme" => { "/readme" => {
println!("COMMAND: readme"); println!("COMMAND: readme");
match agent.reload_readme() { match agent.reload_readme() {
@@ -1801,7 +1862,7 @@ async fn run_interactive_machine(
} }
"/help" => { "/help" => {
println!("COMMAND: help"); println!("COMMAND: help");
println!("AVAILABLE_COMMANDS: /compact /thinnify /skinnify /readme /stats /help"); println!("AVAILABLE_COMMANDS: /compact /thinnify /skinnify /clear /readme /stats /help");
continue; continue;
} }
_ => { _ => {

View File

@@ -3,12 +3,14 @@ pub mod error_handling;
pub mod feedback_extraction; pub mod feedback_extraction;
pub mod project; pub mod project;
pub mod retry; pub mod retry;
pub mod session_continuation;
pub mod task_result; pub mod task_result;
pub mod ui_writer; pub mod ui_writer;
pub use task_result::TaskResult; pub use task_result::TaskResult;
pub use retry::{RetryConfig, RetryResult, execute_with_retry, retry_operation}; pub use retry::{RetryConfig, RetryResult, execute_with_retry, retry_operation};
pub use feedback_extraction::{ExtractedFeedback, FeedbackSource, FeedbackExtractionConfig, extract_coach_feedback}; 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};
// Export agent prompt generation for CLI use // Export agent prompt generation for CLI use
pub use prompts::get_agent_system_prompt; pub use prompts::get_agent_system_prompt;
@@ -606,6 +608,23 @@ impl ContextWindow {
} }
} }
/// Clear the conversation history while preserving system messages
/// Used by /clear command to start fresh
pub fn clear_conversation(&mut self) {
// Keep only system messages (system prompt, README, etc.)
let system_messages: Vec<Message> = self.conversation_history
.iter()
.filter(|m| matches!(m.role, MessageRole::System))
.cloned()
.collect();
self.conversation_history = system_messages;
self.used_tokens = self.conversation_history.iter()
.map(|m| Self::estimate_tokens(&m.content))
.sum();
self.last_thinning_percentage = 0;
}
pub fn remaining_tokens(&self) -> u32 { pub fn remaining_tokens(&self) -> u32 {
self.total_tokens.saturating_sub(self.used_tokens) self.total_tokens.saturating_sub(self.used_tokens)
} }
@@ -3074,6 +3093,133 @@ impl<W: UiWriter> Agent<W> {
self.requirements_sha = Some(sha); self.requirements_sha = Some(sha);
} }
/// Save a session continuation artifact
/// Called when final_output is invoked to enable session resumption
pub fn save_session_continuation(&self, final_output_summary: Option<String>) {
use crate::session_continuation::{save_continuation, SessionContinuation};
let session_id = match &self.session_id {
Some(id) => id.clone(),
None => {
debug!("No session ID, skipping continuation save");
return;
}
};
// Get the session log path
let logs_dir = get_logs_dir();
let session_log_path = logs_dir.join(format!("g3_session_{}.json", session_id));
// Get current TODO content
let todo_snapshot = std::fs::read_to_string(get_todo_path()).ok();
// Get working directory
let working_directory = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
let continuation = SessionContinuation::new(
session_id,
final_output_summary,
session_log_path.to_string_lossy().to_string(),
self.context_window.percentage_used(),
todo_snapshot,
working_directory,
);
if let Err(e) = save_continuation(&continuation) {
error!("Failed to save session continuation: {}", e);
} else {
debug!("Saved session continuation artifact");
}
}
/// Clear session state and continuation artifacts (for /clear command)
pub fn clear_session(&mut self) {
use crate::session_continuation::clear_continuation;
// Clear the context window (keep system prompt)
self.context_window.clear_conversation();
// Clear continuation artifacts
if let Err(e) = clear_continuation() {
error!("Failed to clear continuation artifacts: {}", e);
}
info!("Session cleared");
}
/// Restore session from a continuation artifact
/// Returns true if full context was restored, false if only summary was used
pub fn restore_from_continuation(
&mut self,
continuation: &crate::session_continuation::SessionContinuation,
) -> Result<bool> {
use std::path::PathBuf;
let session_log_path = PathBuf::from(&continuation.session_log_path);
// If context < 80%, try to restore full context
if continuation.can_restore_full_context() && session_log_path.exists() {
// Load the session log
let json = std::fs::read_to_string(&session_log_path)?;
let session_data: serde_json::Value = serde_json::from_str(&json)?;
// Extract conversation history
if let Some(context_window) = session_data.get("context_window") {
if let Some(history) = context_window.get("conversation_history") {
if let Some(messages) = history.as_array() {
// Clear current conversation (keep system messages)
self.context_window.clear_conversation();
// Restore messages from session log (skip system messages as they're preserved)
for msg in messages {
let role_str = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
let role = match role_str {
"system" => continue, // Skip system messages, already preserved
"assistant" => MessageRole::Assistant,
_ => MessageRole::User,
};
self.context_window.add_message(Message {
role,
id: String::new(),
content: content.to_string(),
cache_control: None,
});
}
info!("Restored full context from session log");
return Ok(true);
}
}
}
}
// Fall back to using final_output summary + TODO
let mut context_msg = String::new();
if let Some(ref summary) = continuation.final_output_summary {
context_msg.push_str(&format!("Previous session summary:\n{}\n\n", summary));
}
if let Some(ref todo) = continuation.todo_snapshot {
context_msg.push_str(&format!("Current TODO state:\n{}\n", todo));
}
if !context_msg.is_empty() {
self.context_window.add_message(Message {
role: MessageRole::User,
id: String::new(),
content: format!("[Session Resumed]\n\n{}", context_msg),
cache_control: None,
});
}
info!("Restored session from summary");
Ok(false)
}
async fn stream_completion( async fn stream_completion(
&mut self, &mut self,
request: CompletionRequest, request: CompletionRequest,
@@ -3731,8 +3877,7 @@ impl<W: UiWriter> Agent<W> {
let mut any_tool_executed = false; // Track if ANY tool was executed across all iterations let mut any_tool_executed = false; // Track if ANY tool was executed across all iterations
let mut auto_summary_attempts = 0; // Track auto-summary prompt attempts let mut auto_summary_attempts = 0; // Track auto-summary prompt attempts
const MAX_AUTO_SUMMARY_ATTEMPTS: usize = 2; // Limit auto-summary retries const MAX_AUTO_SUMMARY_ATTEMPTS: usize = 2; // Limit auto-summary retries
let mut last_action_was_tool = false; // Track if the last action was a tool call (vs text response) let mut final_output_called = false; // Track if final_output was called
let mut any_text_response = false; // Track if LLM ever provided a text response
let mut executed_tools_in_session: std::collections::HashSet<String> = std::collections::HashSet::new(); // Track executed tools to prevent duplicates let mut executed_tools_in_session: std::collections::HashSet<String> = std::collections::HashSet::new(); // Track executed tools to prevent duplicates
// Check if we need to summarize before starting // Check if we need to summarize before starting
@@ -4427,6 +4572,7 @@ impl<W: UiWriter> Agent<W> {
// Check if this was a final_output tool call // Check if this was a final_output tool call
if tool_call.tool == "final_output" { if tool_call.tool == "final_output" {
// Save context window BEFORE returning so the session log includes final_output // Save context window BEFORE returning so the session log includes final_output
final_output_called = true;
self.save_context_window("completed"); self.save_context_window("completed");
// The summary was already displayed via print_final_output // The summary was already displayed via print_final_output
@@ -4482,7 +4628,6 @@ impl<W: UiWriter> Agent<W> {
tool_executed = true; tool_executed = true;
any_tool_executed = true; // Track across all iterations any_tool_executed = true; // Track across all iterations
last_action_was_tool = true; // Last action was a tool call
// Add to executed tools set to prevent re-execution in this session // Add to executed tools set to prevent re-execution in this session
executed_tools_in_session.insert(tool_key.clone()); executed_tools_in_session.insert(tool_key.clone());
@@ -4533,8 +4678,6 @@ impl<W: UiWriter> Agent<W> {
self.ui_writer.print_agent_response(&filtered_content); self.ui_writer.print_agent_response(&filtered_content);
self.ui_writer.flush(); self.ui_writer.flush();
current_response.push_str(&filtered_content); current_response.push_str(&filtered_content);
last_action_was_tool = false; // Text response received
any_text_response = true;
} }
} }
} }
@@ -4790,50 +4933,56 @@ impl<W: UiWriter> Agent<W> {
let has_response = !current_response.is_empty() || !full_response.is_empty(); let has_response = !current_response.is_empty() || !full_response.is_empty();
if !has_response { // Auto-continue if tools were executed but final_output was never called
if any_tool_executed && last_action_was_tool && !any_text_response { // This is the simple rule: LLM must call final_output before returning control
// Only auto-prompt for summary if: if any_tool_executed && !final_output_called {
// 1. Tools were executed in previous iterations
// 2. The last action was a tool call (not a text response)
// 3. No text response was ever provided by the LLM
if auto_summary_attempts < MAX_AUTO_SUMMARY_ATTEMPTS { if auto_summary_attempts < MAX_AUTO_SUMMARY_ATTEMPTS {
// Auto-prompt for a summary by adding a follow-up message
auto_summary_attempts += 1; auto_summary_attempts += 1;
warn!( warn!(
"LLM stopped without final response after executing tools ({} iterations, auto-summary attempt {})", "LLM stopped without calling final_output after executing tools ({} iterations, auto-continue attempt {})",
iteration_count, auto_summary_attempts iteration_count, auto_summary_attempts
); );
self.ui_writer.print_context_status( self.ui_writer.print_context_status(
"\n🔄 Model stopped without response. Auto-prompting for summary...\n" "\n🔄 Model stopped without calling final_output. Auto-continuing...\n"
); );
// Add a follow-up message asking for summary // Add any text response to context before prompting for continuation
let summary_prompt = Message::new( if has_response {
MessageRole::User, let response_text = if !current_response.is_empty() {
"Please provide a brief summary of what was accomplished and any next steps.".to_string(), current_response.clone()
} else {
full_response.clone()
};
if !response_text.trim().is_empty() {
let assistant_msg = Message::new(
MessageRole::Assistant,
response_text.trim().to_string(),
); );
self.context_window.add_message(summary_prompt); self.context_window.add_message(assistant_msg);
}
}
// Add a follow-up message asking for continuation
let continue_prompt = Message::new(
MessageRole::User,
"Please continue until you are done. You **MUST** call `final_output` with a summary when done.".to_string(),
);
self.context_window.add_message(continue_prompt);
request.messages = self.context_window.conversation_history.clone(); request.messages = self.context_window.conversation_history.clone();
// Continue the loop to get the summary // Continue the loop
continue; continue;
} else { } else {
// Max auto-summary attempts reached, give up gracefully // Max attempts reached, give up gracefully
warn!( warn!(
"Max auto-summary attempts ({}) reached, returning without summary", "Max auto-continue attempts ({}) reached, returning without final_output",
MAX_AUTO_SUMMARY_ATTEMPTS MAX_AUTO_SUMMARY_ATTEMPTS
); );
self.ui_writer.print_agent_response( self.ui_writer.print_agent_response(
"\n⚠️ The model stopped without providing a final response after multiple attempts.\n" "\n⚠️ The model stopped without calling final_output after multiple attempts.\n"
); );
} }
} else { } else if has_response {
warn!(
"Loop exited without any response after {} iterations",
iteration_count
);
}
} else {
// Only set full_response if it's empty (first iteration without tools) // Only set full_response if it's empty (first iteration without tools)
// This prevents duplication when the agent responds without calling final_output // This prevents duplication when the agent responds without calling final_output
if full_response.is_empty() && !current_response.is_empty() { if full_response.is_empty() && !current_response.is_empty() {
@@ -5387,11 +5536,15 @@ impl<W: UiWriter> Agent<W> {
"final_output" => { "final_output" => {
if let Some(summary) = tool_call.args.get("summary") { if let Some(summary) = tool_call.args.get("summary") {
if let Some(summary_str) = summary.as_str() { if let Some(summary_str) = summary.as_str() {
// Save session continuation artifact
self.save_session_continuation(Some(summary_str.to_string()));
Ok(summary_str.to_string()) Ok(summary_str.to_string())
} else { } else {
self.save_session_continuation(None);
Ok("✅ Turn completed".to_string()) Ok("✅ Turn completed".to_string())
} }
} else { } else {
self.save_session_continuation(None);
Ok("✅ Turn completed".to_string()) Ok("✅ Turn completed".to_string())
} }
} }

View File

@@ -0,0 +1,226 @@
//! Session continuation support for long-running interactive sessions.
//!
//! This module provides functionality to save and restore session state,
//! allowing users to resume work across multiple g3 invocations.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, error, info, warn};
/// Version of the session continuation format
const CONTINUATION_VERSION: &str = "1.0";
/// Session continuation artifact containing all information needed to resume a session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionContinuation {
/// Version of the continuation format
pub version: String,
/// Timestamp when the continuation was saved
pub created_at: String,
/// Original session ID
pub session_id: String,
/// The last final_output summary
pub final_output_summary: Option<String>,
/// Path to the full session log (g3_session_*.json)
pub session_log_path: String,
/// Context window usage percentage when saved
pub context_percentage: f32,
/// Snapshot of the TODO list content
pub todo_snapshot: Option<String>,
/// Working directory where the session was running
pub working_directory: String,
}
impl SessionContinuation {
/// Create a new session continuation artifact
pub fn new(
session_id: String,
final_output_summary: Option<String>,
session_log_path: String,
context_percentage: f32,
todo_snapshot: Option<String>,
working_directory: String,
) -> Self {
Self {
version: CONTINUATION_VERSION.to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
session_id,
final_output_summary,
session_log_path,
context_percentage,
todo_snapshot,
working_directory,
}
}
/// Check if the context can be fully restored (< 80% used)
pub fn can_restore_full_context(&self) -> bool {
self.context_percentage < 80.0
}
}
/// Get the path to the .g3/session directory
pub fn get_session_dir() -> PathBuf {
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
current_dir.join(".g3").join("session")
}
/// Get the path to the latest.json continuation file
pub fn get_latest_continuation_path() -> PathBuf {
get_session_dir().join("latest.json")
}
/// Ensure the .g3/session directory exists
pub fn ensure_session_dir() -> Result<PathBuf> {
let session_dir = get_session_dir();
if !session_dir.exists() {
std::fs::create_dir_all(&session_dir)?;
debug!("Created session directory: {:?}", session_dir);
}
Ok(session_dir)
}
/// Save a session continuation artifact
pub fn save_continuation(continuation: &SessionContinuation) -> Result<PathBuf> {
let session_dir = ensure_session_dir()?;
let latest_path = session_dir.join("latest.json");
let json = serde_json::to_string_pretty(continuation)?;
std::fs::write(&latest_path, &json)?;
info!("Saved session continuation to {:?}", latest_path);
Ok(latest_path)
}
/// Load the latest session continuation artifact if it exists
pub fn load_continuation() -> Result<Option<SessionContinuation>> {
let latest_path = get_latest_continuation_path();
if !latest_path.exists() {
debug!("No continuation file found at {:?}", latest_path);
return Ok(None);
}
let json = std::fs::read_to_string(&latest_path)?;
let continuation: SessionContinuation = serde_json::from_str(&json)?;
// Validate version
if continuation.version != CONTINUATION_VERSION {
warn!(
"Continuation version mismatch: expected {}, got {}",
CONTINUATION_VERSION, continuation.version
);
}
info!("Loaded session continuation from {:?}", latest_path);
Ok(Some(continuation))
}
/// Clear all session continuation artifacts (for /clear command)
pub fn clear_continuation() -> Result<()> {
let session_dir = get_session_dir();
if session_dir.exists() {
// Remove all files in the session directory
for entry in std::fs::read_dir(&session_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
std::fs::remove_file(&path)?;
debug!("Removed session file: {:?}", path);
}
}
info!("Cleared session continuation artifacts");
}
Ok(())
}
/// Check if a continuation exists and is valid
pub fn has_valid_continuation() -> bool {
match load_continuation() {
Ok(Some(continuation)) => {
// Check if the session log still exists
let session_log_path = PathBuf::from(&continuation.session_log_path);
if !session_log_path.exists() {
warn!("Session log no longer exists: {:?}", session_log_path);
return false;
}
// Check if we're in the same working directory
let current_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if current_dir != continuation.working_directory {
debug!(
"Working directory changed: {} -> {}",
continuation.working_directory, current_dir
);
// Still valid, but user should be aware
}
true
}
Ok(None) => false,
Err(e) => {
error!("Error checking continuation: {}", e);
false
}
}
}
/// Load the full context window from a session log file
pub fn load_context_from_session_log(session_log_path: &Path) -> Result<Option<serde_json::Value>> {
if !session_log_path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(session_log_path)?;
let session_data: serde_json::Value = serde_json::from_str(&json)?;
Ok(Some(session_data))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_session_continuation_creation() {
let continuation = SessionContinuation::new(
"test_session_123".to_string(),
Some("Task completed successfully".to_string()),
"/path/to/session.json".to_string(),
45.0,
Some("- [x] Task 1\n- [ ] Task 2".to_string()),
"/home/user/project".to_string(),
);
assert_eq!(continuation.version, CONTINUATION_VERSION);
assert_eq!(continuation.session_id, "test_session_123");
assert!(continuation.can_restore_full_context());
}
#[test]
fn test_can_restore_full_context() {
let mut continuation = SessionContinuation::new(
"test".to_string(),
None,
"path".to_string(),
50.0,
None,
".".to_string(),
);
assert!(continuation.can_restore_full_context()); // 50% < 80%
continuation.context_percentage = 80.0;
assert!(!continuation.can_restore_full_context()); // 80% >= 80%
continuation.context_percentage = 95.0;
assert!(!continuation.can_restore_full_context()); // 95% >= 80%
}
}

View File

@@ -0,0 +1,297 @@
//! Tests for session continuation functionality
//!
//! Note: These tests use serial execution because they modify the current directory
use g3_core::session_continuation::{
SessionContinuation, clear_continuation, ensure_session_dir,
get_latest_continuation_path, get_session_dir, has_valid_continuation,
load_continuation, save_continuation,
};
use std::fs;
use std::sync::Mutex;
use tempfile::TempDir;
// Global mutex to ensure tests run serially (they modify current directory)
static TEST_MUTEX: Mutex<()> = Mutex::new(());
/// Helper to set up a test environment with a temporary directory
/// Returns the temp dir (must be kept alive) and the original directory
fn setup_test_env() -> (TempDir, std::path::PathBuf) {
let original_dir = std::env::current_dir().expect("Failed to get current dir");
let temp_dir = TempDir::new().expect("Failed to create temp dir");
std::env::set_current_dir(temp_dir.path()).expect("Failed to change to temp dir");
(temp_dir, original_dir)
}
/// Restore the original directory
fn teardown_test_env(original_dir: std::path::PathBuf) {
let _ = std::env::set_current_dir(original_dir);
}
#[test]
fn test_session_continuation_creation() {
// This test doesn't need file system access
let continuation = SessionContinuation::new(
"test_session_123".to_string(),
Some("Task completed successfully".to_string()),
"/path/to/session.json".to_string(),
45.0,
Some("- [x] Task 1\n- [ ] Task 2".to_string()),
"/home/user/project".to_string(),
);
assert_eq!(continuation.session_id, "test_session_123");
assert_eq!(
continuation.final_output_summary,
Some("Task completed successfully".to_string())
);
assert_eq!(continuation.context_percentage, 45.0);
assert!(continuation.can_restore_full_context()); // 45% < 80%
}
#[test]
fn test_can_restore_full_context_threshold() {
// This test doesn't need file system access
let test_cases = vec![
(0.0, true),
(50.0, true),
(79.9, true),
(80.0, false),
(80.1, false),
(95.0, false),
(100.0, false),
];
for (percentage, expected) in test_cases {
let continuation = SessionContinuation::new(
"test".to_string(),
None,
"path".to_string(),
percentage,
None,
".".to_string(),
);
assert_eq!(
continuation.can_restore_full_context(),
expected,
"Failed for percentage {}",
percentage
);
}
}
#[test]
fn test_save_and_load_continuation() {
let _lock = TEST_MUTEX.lock().unwrap();
let (temp_dir, original_dir) = setup_test_env();
let original = SessionContinuation::new(
"save_load_test".to_string(),
Some("Test summary content".to_string()),
"/logs/g3_session_save_load_test.json".to_string(),
35.5,
Some("- [ ] Pending task".to_string()),
temp_dir.path().to_string_lossy().to_string(),
);
// Save the continuation
let saved_path = save_continuation(&original).expect("Failed to save continuation");
assert!(saved_path.exists());
// Load it back
let loaded = load_continuation()
.expect("Failed to load continuation")
.expect("No continuation found");
assert_eq!(loaded.session_id, original.session_id);
assert_eq!(loaded.final_output_summary, original.final_output_summary);
assert_eq!(loaded.session_log_path, original.session_log_path);
assert!((loaded.context_percentage - original.context_percentage).abs() < 0.01);
assert_eq!(loaded.todo_snapshot, original.todo_snapshot);
assert_eq!(loaded.working_directory, original.working_directory);
teardown_test_env(original_dir);
}
#[test]
fn test_load_continuation_when_none_exists() {
let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env();
// No continuation should exist in a fresh temp directory
let result = load_continuation().expect("load_continuation should not error");
assert!(result.is_none());
teardown_test_env(original_dir);
}
#[test]
fn test_clear_continuation() {
let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env();
// Create and save a continuation
let continuation = SessionContinuation::new(
"clear_test".to_string(),
Some("Will be cleared".to_string()),
"/path/to/session.json".to_string(),
50.0,
None,
".".to_string(),
);
save_continuation(&continuation).expect("Failed to save");
// Verify it exists
assert!(get_latest_continuation_path().exists());
// Clear it
clear_continuation().expect("Failed to clear");
// Verify it's gone
assert!(!get_latest_continuation_path().exists());
// Loading should return None
let result = load_continuation().expect("load should not error");
assert!(result.is_none());
teardown_test_env(original_dir);
}
#[test]
fn test_ensure_session_dir_creates_directory() {
let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env();
let session_dir = get_session_dir();
assert!(!session_dir.exists());
ensure_session_dir().expect("Failed to ensure session dir");
assert!(session_dir.exists());
assert!(session_dir.is_dir());
teardown_test_env(original_dir);
}
#[test]
fn test_has_valid_continuation_with_missing_session_log() {
let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env();
// Create a continuation pointing to a non-existent session log
let continuation = SessionContinuation::new(
"invalid_test".to_string(),
Some("Summary".to_string()),
"/nonexistent/path/session.json".to_string(),
30.0,
None,
".".to_string(),
);
save_continuation(&continuation).expect("Failed to save");
// Should be invalid because session log doesn't exist
assert!(!has_valid_continuation());
teardown_test_env(original_dir);
}
#[test]
fn test_has_valid_continuation_with_existing_session_log() {
let _lock = TEST_MUTEX.lock().unwrap();
let (temp_dir, original_dir) = setup_test_env();
// Create a fake session log file
let logs_dir = temp_dir.path().join("logs");
fs::create_dir_all(&logs_dir).expect("Failed to create logs dir");
let session_log_path = logs_dir.join("g3_session_valid_test.json");
fs::write(&session_log_path, "{}").expect("Failed to write session log");
// Create a continuation pointing to the existing session log
let continuation = SessionContinuation::new(
"valid_test".to_string(),
Some("Summary".to_string()),
session_log_path.to_string_lossy().to_string(),
30.0,
None,
temp_dir.path().to_string_lossy().to_string(),
);
save_continuation(&continuation).expect("Failed to save");
// Should be valid because session log exists
assert!(has_valid_continuation());
teardown_test_env(original_dir);
}
#[test]
fn test_continuation_serialization_format() {
let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env();
let continuation = SessionContinuation::new(
"format_test".to_string(),
Some("Test summary".to_string()),
"/path/to/session.json".to_string(),
42.5,
Some("- [x] Done\n- [ ] Todo".to_string()),
"/workspace".to_string(),
);
save_continuation(&continuation).expect("Failed to save");
// Read the raw JSON and verify structure
let json_content =
fs::read_to_string(get_latest_continuation_path()).expect("Failed to read file");
let parsed: serde_json::Value =
serde_json::from_str(&json_content).expect("Failed to parse JSON");
assert_eq!(parsed["version"], "1.0");
assert_eq!(parsed["session_id"], "format_test");
assert_eq!(parsed["final_output_summary"], "Test summary");
assert_eq!(parsed["session_log_path"], "/path/to/session.json");
assert!((parsed["context_percentage"].as_f64().unwrap() - 42.5).abs() < 0.01);
assert_eq!(parsed["todo_snapshot"], "- [x] Done\n- [ ] Todo");
assert_eq!(parsed["working_directory"], "/workspace");
assert!(parsed["created_at"].as_str().is_some()); // Should have a timestamp
teardown_test_env(original_dir);
}
#[test]
fn test_multiple_saves_overwrite() {
let _lock = TEST_MUTEX.lock().unwrap();
let (_temp_dir, original_dir) = setup_test_env();
// Save first continuation
let first = SessionContinuation::new(
"first_session".to_string(),
Some("First summary".to_string()),
"/path/first.json".to_string(),
20.0,
None,
".".to_string(),
);
save_continuation(&first).expect("Failed to save first");
// Save second continuation (should overwrite)
let second = SessionContinuation::new(
"second_session".to_string(),
Some("Second summary".to_string()),
"/path/second.json".to_string(),
60.0,
None,
".".to_string(),
);
save_continuation(&second).expect("Failed to save second");
// Load should return the second one
let loaded = load_continuation()
.expect("Failed to load")
.expect("No continuation");
assert_eq!(loaded.session_id, "second_session");
assert_eq!(
loaded.final_output_summary,
Some("Second summary".to_string())
);
teardown_test_env(original_dir);
}