diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-14 16:03:08 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-15 00:03:08 +0100 |
| commit | fd188da879d977ca847f10708c39dd4801a204c4 (patch) | |
| tree | 592bfe2644f8bd9be3563f176eabf29e55fa9a9b /crates/atuin-ai/src/commands | |
| parent | fix: dependency fix (#3414) (diff) | |
| download | atuin-fd188da879d977ca847f10708c39dd4801a204c4.zip | |
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.
<img width="1055" height="593" alt="image"
src="https://github.com/user-attachments/assets/3f9ff01a-ef64-44a9-b0e2-3a4252c5746f"
/>
## 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.
Diffstat (limited to 'crates/atuin-ai/src/commands')
| -rw-r--r-- | crates/atuin-ai/src/commands/inline.rs | 74 |
1 files changed, 69 insertions, 5 deletions
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<String>) -> Result<Action> { +async fn run_inline_tui( + ctx: AppContext, + initial_prompt: Option<String>, + settings: &atuin_client::settings::Settings, +) -> Result<Action> { let client_ctx = ClientContext::detect(); - let (tx, rx) = mpsc::channel::<AiTuiEvent>(); + // 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::<AiTuiEvent>(); println!(); @@ -177,8 +240,9 @@ async fn run_inline_tui(ctx: AppContext, initial_prompt: Option<String>) -> 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); } }); |
