From fd188da879d977ca847f10708c39dd4801a204c4 Mon Sep 17 00:00:00 2001 From: Michelle Tilley Date: Tue, 14 Apr 2026 16:03:08 -0700 Subject: feat: Allow resuming previous AI sessions (#3407) This PR introduces session continuation to Atuin AI. * Conversations with Atuin AI are stored in a local SQLite database * Upon startup, Atuin AI tries to find a session to resume based on its directory/workspace and the time since the last event * If found, Atuin AI will show a note that the session has been resumed, and an event is added to help the LLM know where the invocation boundaries are * If not, Atuin AI will create a new conversation * The user can create a new conversation with `/new` * The new setting `ai.session_continue_minutes`, which defaults to `60`, controls how old the last event in a session can be before it's no longer considered for automatic resuming. image ## Architecture A new `SessionService` trait defines an API contract for a service that can manage session data. `LocalSessionService` implements this, with `DaemonSessionService` a possible future extension point. `SessionManager` owns a `dyn SessionService` and delegates as appropriate. --- crates/atuin-ai/src/commands/inline.rs | 74 +++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) (limited to 'crates/atuin-ai/src/commands/inline.rs') diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index b37bb72f..2e6beca2 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::mpsc; use crate::context::{AppContext, ClientContext}; +use crate::session::{LocalSessionService, SessionManager, SessionService}; use crate::tui::dispatch; use crate::tui::events::AiTuiEvent; use crate::tui::state::{ExitAction, Session}; @@ -83,7 +84,7 @@ pub(crate) async fn run( capabilities: settings.ai.capabilities.clone(), }; - let action = run_inline_tui(ctx, initial_command).await?; + let action = run_inline_tui(ctx, initial_command, settings).await?; emit_shell_result(action, output_for_hook); Ok(()) @@ -147,12 +148,74 @@ async fn ensure_hub_session(settings: &atuin_client::settings::Settings) -> Resu // ─────────────────────────────────────────────────────────────────── -async fn run_inline_tui(ctx: AppContext, initial_prompt: Option) -> Result { +async fn run_inline_tui( + ctx: AppContext, + initial_prompt: Option, + settings: &atuin_client::settings::Settings, +) -> Result { let client_ctx = ClientContext::detect(); - let (tx, rx) = mpsc::channel::(); + // Open the session service and check for a resumable session + let service = LocalSessionService::open(&settings.ai.db_path, settings.local_timeout) + .await + .context("failed to open AI session database")?; + + let cwd = std::env::current_dir() + .ok() + .map(|p| p.to_string_lossy().into_owned()); + let git_root_str = ctx + .git_root + .as_ref() + .map(|p| p.to_string_lossy().into_owned()); + + let session_window_mins = settings.ai.session_continue_minutes.max(0); // treat negative values as 0 to avoid confusion + let max_age_secs: i64 = session_window_mins * 60; + + let resumable = service + .find_resumable(cwd.as_deref(), git_root_str.as_deref(), max_age_secs) + .await?; - let initial_state = Session::new(ctx.git_root.is_some()); + let (session_mgr, initial_state) = if let Some(stored) = resumable { + debug!(session_id = %stored.id, "resuming AI session"); + let (mgr, events, server_sid, last_event_ts, invocation_id) = + SessionManager::resume(Box::new(service), &stored).await?; + + // Only treat this as a meaningful resume if there are API-visible events + // (not just OutOfBandOutput or SystemContext). + let has_api_content = events.iter().any(|e| e.is_api_content()); + + if has_api_content { + let mut session = Session::new(ctx.git_root.is_some(), Some(invocation_id)); + session.conversation.events = events; + session.conversation.session_id = server_sid; + // Inject an invocation boundary so the LLM knows prior messages + // are from an earlier interaction. + session.conversation.events.push( + crate::tui::state::ConversationEvent::SystemContext { + content: "[Note: The user has started a new invocation of Atuin AI. Prior messages from this session are from an earlier invocation.]".to_string(), + }, + ); + session.view_start_index = session.conversation.events.len(); + session.is_resumed = true; + session.last_event_time = + last_event_ts.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)); + (mgr, session) + } else { + // No meaningful content — treat as a fresh session + debug!("resumable session has no API-visible content, starting fresh"); + ( + mgr, + Session::new(ctx.git_root.is_some(), Some(invocation_id)), + ) + } + } else { + debug!("creating new AI session"); + let mgr = + SessionManager::create_new(Box::new(service), cwd.as_deref(), git_root_str.as_deref()); + (mgr, Session::new(ctx.git_root.is_some(), None)) + }; + + let (tx, rx) = mpsc::channel::(); println!(); @@ -177,8 +240,9 @@ async fn run_inline_tui(ctx: AppContext, initial_prompt: Option) -> Resu tokio::task::spawn_blocking(move || { let tx = tx.clone(); let client_ctx = client_ctx; + let mut session_mgr = session_mgr; while let Ok(event) = rx.recv() { - dispatch::dispatch(&h, event, &tx, &ctx, &client_ctx); + dispatch::dispatch(&h, event, &tx, &ctx, &client_ctx, &mut session_mgr); } }); -- cgit v1.3.1