diff --git a/app/Cargo.toml b/app/Cargo.toml index 56ceb75996a..3396d293e07 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -792,6 +792,7 @@ list_skills = [] local_tty = [] local_computer_use = [] local_claude_codex_child_harnesses = [] +agent_session_resume = [] # This feature is enabled in build.rs when compiling for platforms which # have APIs for interacting with a local filesystem. It can be used to # conditionally include dependencies that should only exist in such diff --git a/app/assets/bundled/bootstrap/bash_body.sh b/app/assets/bundled/bootstrap/bash_body.sh index a6e27426320..f5f635987eb 100644 --- a/app/assets/bundled/bootstrap/bash_body.sh +++ b/app/assets/bundled/bootstrap/bash_body.sh @@ -1239,10 +1239,12 @@ esac # Add a pattern to ignore in-band commands in shell history, while preserving the user's # HISTIGNORE value which may been set in an RC file sourced above. It is important to # ensure that this happens _after_ the user's RC files have been sourced. + # `warp_resume_agent_session` is the trailing-comment marker Warp appends to the + # invocation it runs when restoring a pane onto an agent's previous session. if [[ ! -z $HISTIGNORE ]]; then - HISTIGNORE="*warp_run_generator_command*:$HISTIGNORE" + HISTIGNORE="*warp_run_generator_command*:*warp_resume_agent_session*:$HISTIGNORE" else - HISTIGNORE="*warp_run_generator_command*" + HISTIGNORE="*warp_run_generator_command*:*warp_resume_agent_session*" fi # If the user has PROMPT_COMMAND set in their bootstrap scripts, diff --git a/app/assets/bundled/bootstrap/pwsh.ps1 b/app/assets/bundled/bootstrap/pwsh.ps1 index 016e12d767f..6258325984e 100644 --- a/app/assets/bundled/bootstrap/pwsh.ps1 +++ b/app/assets/bundled/bootstrap/pwsh.ps1 @@ -402,6 +402,11 @@ $null = New-Module -Name Warp-Module -ScriptBlock { if ($line -match '^Warp-Run-GeneratorCommand') { return $false } + # Trailing-comment marker Warp appends to the invocation it runs when + # restoring a pane onto an agent's previous session. + if ($line -match 'warp_resume_agent_session') { + return $false + } return $true } diff --git a/app/assets/bundled/bootstrap/zsh_body.sh b/app/assets/bundled/bootstrap/zsh_body.sh index e992062c4a2..bf6ca237c77 100644 --- a/app/assets/bundled/bootstrap/zsh_body.sh +++ b/app/assets/bundled/bootstrap/zsh_body.sh @@ -249,6 +249,15 @@ if [[ -z $WARP_BOOTSTRAPPED ]]; then [[ "$1" != *"warp_run_generator_command"* ]] } + # Returns exit code 1 if the given argument carries the agent-resume marker. + # + # Warp appends this marker to the invocation it runs when restoring a pane onto + # an agent's previous session. The marker is a trailing comment, so it is inert + # to the shell and identifies the line without a wrapper function. + _is_warp_agent_resume_command() { + [[ "$1" != *"warp_resume_agent_session"* ]] + } + # Note that this is very performance sensitive code, so try not to # invoke any external commands in here. warp_preexec () { @@ -1212,16 +1221,18 @@ esac POWERLEVEL9K_PROMPT_ADD_NEWLINE=false fi - # Returns exit code 1 if the command starts with 'warp_run_generator_command'. + # Returns exit code 1 if the command starts with 'warp_run_generator_command', + # or carries the agent-resume marker. # # This is intended to be used as a zshaddhistory function to prevent in-band - # generators from being added to the zsh history file. + # generators and restored agent-session invocations from being added to the + # zsh history file. # zshaddhistory functions. # # See https://zsh.sourceforge.io/Doc/Release/Functions.html for more context # on the zshaddhistory hook. _warp_zshaddhistory() { - _is_warp_generator_command "$1" + _is_warp_generator_command "$1" && _is_warp_agent_resume_command "$1" } # Register this zshaddhistory hook after the user's RC files have been sourced, diff --git a/app/resources/cli_agent_resume/agents.toml b/app/resources/cli_agent_resume/agents.toml new file mode 100644 index 00000000000..b03ab6c1a10 --- /dev/null +++ b/app/resources/cli_agent_resume/agents.toml @@ -0,0 +1,80 @@ +# Per-agent support for resuming a CLI agent session after Warp restarts. +# +# This file is embedded into the binary at build time and is the only place an +# agent is added: there is no per-agent restore path in code. +# +# It deliberately never names an executable. The binary comes from the detected +# agent's `CLIAgent::command_prefixes()`, so a declaration here cannot introduce +# a new process for Warp to run. +# +# Each declaration supplies: +# resume - the invocation shape that reattaches to a prior session. +# identifier - the value shape the recorded session id must satisfy. +# flags - the allowlist of flags carried over from the user's own +# invocation, each with the shape its value must satisfy. +# +# Every recorded value is untrusted: it comes from a local database file that +# any process running as the user can write, and the built string is handed to +# an interactive shell. A value that does not match its declared shape is +# dropped, never repaired, and every surviving value is shell-quoted. Widening a +# shape here widens what can reach the shell, so keep each character set as +# narrow as the CLI actually needs. +# +# Value shapes: +# boolean - the flag stands alone and carries no value. +# bare_token - ASCII letters, digits and `._-+:@`, not starting with `-`. +# path_like - `bare_token` plus `/`. +# +# A flag that chooses the agent's permission posture rather than describing the +# session is marked `permission_posture = true`. Those flags are the only ones +# bounded by a freshness window (`PERMISSION_POSTURE_FRESHNESS`): a recording +# older than the window resumes the same conversation without them, so a +# long-abandoned elevation is not revived by a restart. Mark every spelling that +# can elevate, including one that carries its posture as a value. +# +# Only flags that take at most one value belong here. A variadic flag cannot be +# told apart from a trailing prompt positional once the command line is +# tokenized, so carrying one risks turning the user's prompt into an argument. +# That rules out Claude Code's `--add-dir` and `--mcp-config`. +# +# Nor does a flag whose value names a file Warp cannot validate belong here. A +# shape checks the string, never what the file says, and a resume runs unattended +# at startup — so replaying such a pointer replays whatever the file has come to +# hold since. That rules out Claude Code's `--settings`, which can carry hooks +# and a permission mode: a code-execution pointer, and one the posture freshness +# window does not bound because it is not a posture flag. +# +# An agent absent from this file simply does not offer resume, which is the safe +# default and needs no code change: +# Gemini - its `--resume` has not been verified against a released CLI, and +# the in-repo harness still reports no conversation resume. +# WarpTui - the session id it reports over OSC is a process-local entity id +# rather than the token its `--resume` accepts, so a resume built +# from what we record could never reattach. + +[agents.Claude] +# Verified against Claude Code 2.1.227: `claude --resume `. +resume = { form = "flag", flag = "--resume" } +identifier = { shape = "bare_token", max_length = 128 } + +# `--fork-session` and `--session-id` are deliberately absent: the first asks +# for a new session id when resuming and the second starts a new session +# outright, so either one would defeat the resume it rode in on. +[agents.Claude.flags] +"--model" = { shape = "path_like", max_length = 128 } +"--permission-mode" = { shape = "bare_token", max_length = 32, permission_posture = true } +# The permission posture the user chose. Carried because a pane that comes back +# re-prompting is not the session it replaced; never added when it was absent. +"--dangerously-skip-permissions" = { shape = "boolean", permission_posture = true } +"--strict-mcp-config" = { shape = "boolean" } +"--agent" = { shape = "bare_token", max_length = 64 } + +[agents.Codex] +# `codex resume `, the shape Warp's own headless driver already +# uses. Codex is not installed here, so only flags with in-repo evidence are +# allowlisted. +resume = { form = "subcommand", subcommand = "resume" } +identifier = { shape = "bare_token", max_length = 128 } + +[agents.Codex.flags] +"--dangerously-bypass-approvals-and-sandbox" = { shape = "boolean", permission_posture = true } diff --git a/app/src/ai/blocklist/context_model.rs b/app/src/ai/blocklist/context_model.rs index 791772e8fe8..cad8c3313cc 100644 --- a/app/src/ai/blocklist/context_model.rs +++ b/app/src/ai/blocklist/context_model.rs @@ -171,7 +171,7 @@ impl BlocklistAIContextModel { .conversation_selection .as_ref(ctx) .is_conversation_fullscreen(ctx) - && !user_block_completed.was_part_of_agent_interaction + && user_block_completed.was_user_authored() { me.auto_attached_agent_view_user_block_ids .push(block_id.clone()); @@ -180,7 +180,7 @@ impl BlocklistAIContextModel { // If the block that finished was part of an agent interaction (i.e. LRC finishing), // we should preserve input context. if !FeatureFlag::AgentViewBlockContext.is_enabled() - && !user_block_completed.was_part_of_agent_interaction + && user_block_completed.was_user_authored() { me.reset_context_to_default(ctx); } diff --git a/app/src/ai/blocklist/passive_suggestions/legacy.rs b/app/src/ai/blocklist/passive_suggestions/legacy.rs index 3b88a712347..00ad7a4b41a 100644 --- a/app/src/ai/blocklist/passive_suggestions/legacy.rs +++ b/app/src/ai/blocklist/passive_suggestions/legacy.rs @@ -220,7 +220,7 @@ impl PassiveSuggestionsModel { block_completed: &UserBlockCompleted, ctx: &mut ModelContext, ) { - if block_completed.was_part_of_agent_interaction { + if !block_completed.was_user_authored() { return; } diff --git a/app/src/ai/blocklist/passive_suggestions/maa.rs b/app/src/ai/blocklist/passive_suggestions/maa.rs index e6ddb27354a..d2f4fa73da0 100644 --- a/app/src/ai/blocklist/passive_suggestions/maa.rs +++ b/app/src/ai/blocklist/passive_suggestions/maa.rs @@ -375,7 +375,7 @@ impl PassiveSuggestionsModel { return; } if let BlockType::User(block_completed) = &after_block_completed_event.block_type - && !block_completed.was_part_of_agent_interaction + && block_completed.was_user_authored() { self.handle_user_block_completed(block_completed, ctx); } diff --git a/app/src/ai/predict/next_command_model.rs b/app/src/ai/predict/next_command_model.rs index 5d993bdce1d..877cc2d57fb 100644 --- a/app/src/ai/predict/next_command_model.rs +++ b/app/src/ai/predict/next_command_model.rs @@ -241,6 +241,16 @@ impl NextCommandModel { self.zerostate_suggestion_info.as_ref() } + /// Seeds the state a completed zero-state prediction would have left behind, so tests can + /// exercise the command-text telemetry arm without a server round trip. + #[cfg(test)] + pub(crate) fn set_zero_state_suggestion_info_for_test( + &mut self, + info: ZeroStateSuggestionInfo, + ) { + self.zerostate_suggestion_info = Some(info); + } + pub fn clear_state(&mut self) { self.next_command_state = NextCommandSuggestionState::None; self.cached_zerostate_next_command_context = None; diff --git a/app/src/app_state.rs b/app/src/app_state.rs index d288cc8c770..c608aff9cfb 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -1,7 +1,8 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; +use chrono::NaiveDateTime; use pathfinder_geometry::rect::RectF; use serde::{Deserialize, Serialize}; use warpui::platform::FullscreenState; @@ -18,7 +19,8 @@ use crate::server::ids::{ServerId, SyncId}; use crate::settings_view::SettingsSection; use crate::settings_view::environments_page::EnvironmentsPage; use crate::tab::SelectedTabColor; -use crate::terminal::ShellLaunchData; +use crate::terminal::cli_agent_resume::RecordedFlag; +use crate::terminal::{CLIAgent, ShellLaunchData}; use crate::themes::theme::AnsiColorIdentifier; use crate::workspace::WorkspaceRegistry; use crate::workspace::tab_group::TabGroupId; @@ -29,12 +31,52 @@ pub struct AppState { pub windows: Vec, pub active_window_index: Option, pub block_lists: Arc>>, + /// Agent CLI state recorded per pane. Unlike the rest of this struct it is not written by a + /// snapshot save; it is read from its own table, which snapshot saves leave alone. + pub agent_sessions: Arc>, pub running_mcp_servers: Vec, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct PaneUuid(pub Vec); +/// The agent CLI a pane was last observed running, recorded so a restart can offer to resume it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RecordedAgentSession { + pub agent: CLIAgent, + /// The session identifier the agent itself reported. + pub session_id: String, + /// The allowlisted flags of the invocation the user ran that matter when relaunching the + /// agent. Never the command line itself: replaying what the user typed would replay secret + /// placeholders and unexpandable aliases along with it. + pub flags: Vec, + /// The directory the agent was running in. Recorded here rather than read back from the + /// pane snapshot so that eligibility can compare it against the directory the pane + /// actually restored into. + pub directory: PathBuf, + pub observed_at: NaiveDateTime, +} + +/// Recorded agent sessions handed to pane restoration. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AgentSessionRestore { + pub sessions: Arc>, + /// The panes that own the identifier they recorded, resolved across every window before the + /// first one is created. Panes left out of it recorded an identifier another pane won. + pub claimed_panes: Arc>, + /// Mid-session restores (a tab added from a snapshot) reach the same restore path as + /// startup, and resuming an agent there would be wrong, so the startup pass says so + /// explicitly instead of leaving it to be inferred. + pub is_startup_restore: bool, +} + +impl AgentSessionRestore { + /// Whether `pane_uuid` is the pane that gets to resume the identifier it recorded. + pub fn owns_recorded_identifier(&self, pane_uuid: &PaneUuid) -> bool { + self.claimed_panes.contains(pane_uuid) + } +} + /// Wrapper for persisting agent management filters to restore. #[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PersistedAgentManagementFilters { @@ -392,6 +434,7 @@ pub fn get_app_state(app: &AppContext) -> AppState { windows, active_window_index, block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Vec::new(), } } diff --git a/app/src/features.rs b/app/src/features.rs index 63ac5c65f6b..9ae20d71afe 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -353,6 +353,8 @@ fn enabled_features() -> HashSet { FeatureFlag::BackgroundComputerUse, #[cfg(feature = "local_claude_codex_child_harnesses")] FeatureFlag::LocalClaudeCodexChildHarnesses, + #[cfg(feature = "agent_session_resume")] + FeatureFlag::AgentSessionResume, #[cfg(feature = "team_api_keys")] FeatureFlag::TeamApiKeys, #[cfg(feature = "named_agents")] diff --git a/app/src/launch_configs/launch_config_tests.rs b/app/src/launch_configs/launch_config_tests.rs index 36fc49d4c7e..a1b087e28c4 100644 --- a/app/src/launch_configs/launch_config_tests.rs +++ b/app/src/launch_configs/launch_config_tests.rs @@ -39,6 +39,7 @@ fn single_tab_snapshot(root: PaneNodeSnapshot) -> AppState { }], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), } } @@ -65,6 +66,7 @@ fn multi_tab_snapshot(active_tab_index: usize, tabs: Vec) -> AppSta }], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), } } diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 49757139cba..de86f4f256c 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -1,12 +1,14 @@ use std::any::Any; use std::cell::RefCell; +use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; use std::sync::mpsc::SyncSender; +use chrono::{NaiveDateTime, Utc}; use itertools::Itertools; use lazy_static::lazy_static; use markdown_parser::FormattedTextFragment; @@ -68,9 +70,10 @@ use crate::ai_assistant::AskAIType; #[cfg(feature = "local_fs")] use crate::app_state::CodePaneSnapShot; use crate::app_state::{ - self, AIFactPaneSnapshot, BranchSnapshot, EnvVarCollectionPaneSnapshot, LeafContents, - LeafSnapshot, NotebookPaneSnapshot, PaneNodeSnapshot, PaneUuid, SettingsPaneSnapshot, - TerminalPaneSnapshot, WorkflowPaneSnapshot, + self, AIFactPaneSnapshot, AgentSessionRestore, BranchSnapshot, EnvVarCollectionPaneSnapshot, + LeafContents, LeafSnapshot, NotebookPaneSnapshot, PaneNodeSnapshot, PaneUuid, + RecordedAgentSession, SettingsPaneSnapshot, TerminalPaneSnapshot, WindowSnapshot, + WorkflowPaneSnapshot, }; use crate::appearance::Appearance; use crate::auth::AuthStateProvider; @@ -118,6 +121,7 @@ use crate::settings_view::SettingsSection; use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; use crate::shell_indicator::ShellIndicatorType; use crate::terminal::available_shells::{AvailableShell, AvailableShells}; +use crate::terminal::cli_agent_resume::{PermissionPosture, ResumeDeclarations}; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind; use crate::terminal::focus_env::add_session_focus_env_vars; @@ -175,10 +179,12 @@ mod ambient_pane_restoration; mod child_agent; pub mod focus_state; pub mod pane; +mod telemetry; pub mod tree; pub mod working_directories; use ambient_pane_restoration::AmbientRestoreKind; use focus_state::PaneGroupFocusState; +use telemetry::{AgentSessionResumeTelemetryEvent, ResumeOutcome}; #[cfg(test)] #[path = "mod_tests.rs"] @@ -1090,6 +1096,242 @@ type InitialLayoutCallback = Box< ) -> (PaneData, InitialFocus), >; +/// Why a restored pane will not resume the agent session recorded for it. +/// +/// Every rejection is silent: no marker, badge, message, or toast. Explaining a skipped resume +/// would put a Warp-specific artifact into a pane the user expects to look like their own +/// terminal, so these variants exist to be counted, never shown — which is also why each +/// rejection has its own variant rather than collapsing into one "not eligible". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum ResumeIneligibility { + /// Nothing was recorded for this pane, which is the ordinary case for every pane that was + /// not running an agent. + NoRecordedSession, + /// The pane has recorded state, but this is not the startup restore pass: a tab added from + /// a snapshot mid-session must not relaunch an agent the user did not just lose. + NotStartupRestore, + /// The recording carries no identifier to reattach to. Warp never picks a session on any + /// other basis, so there is nothing to resume. + NoSessionIdentifier, + /// The agent has no resume declaration, so there is no invocation that reattaches. + AgentNotDeclared, + /// The pane was viewing someone else's shared session; the agent never ran here. + SharedSessionViewer, + /// The pane's session did not run on this machine, so a local relaunch would reattach to + /// nothing. The save path drops the cwd for a remote session and for a local one whose + /// directory was already gone, so this also stands for the second, equally unresumable case. + SessionNotLocal, + /// The recorded directory no longer resolves — a deleted worktree, say — and resuming in + /// the fallback directory would run the agent somewhere it never was. + RecordedDirectoryMissing, + /// The recorded directory still resolves, but the pane's shell came up somewhere else. + RestoredElsewhere, + /// Another pane claims the same identifier and won it. + IdentifierClaimedByAnotherPane, +} + +/// The panes that own the session identifier they recorded, resolved over the loaded store +/// before any window exists. +/// +/// Several panes can record one identifier — a duplicated tab, a snapshot restored twice — and +/// only one of them may resume it. Windows are separate `add_window` calls with no owner between +/// them, so the decision cannot be made per window; and the window the user lands in is created +/// last, so a first-wins rule over restore order would hand the session to a pane the user +/// cannot see. +pub(crate) fn resolve_agent_session_claims( + windows: &[WindowSnapshot], + active_window_index: Option, + sessions: &HashMap, +) -> HashSet { + let mut winners: HashMap<&str, (ClaimRank, PaneUuid)> = HashMap::new(); + + for (window_index, window) in windows.iter().enumerate() { + for (tab_index, tab) in window.tabs.iter().enumerate() { + let mut leaves = Vec::new(); + collect_terminal_leaves(&tab.root, &mut leaves); + for (leaf, terminal) in leaves { + let pane_uuid = PaneUuid(terminal.uuid.clone()); + let Some(recorded) = sessions.get(&pane_uuid) else { + continue; + }; + // A recording without an identifier claims nothing: there is no session to win. + if recorded.session_id.is_empty() { + continue; + } + // Only a pane that could plausibly resume competes. A winner that goes on to fail + // the gate would otherwise take the identifier out of reach of a pane that would + // have passed it, and neither would come back. + if !could_resume_from_snapshot(terminal, recorded) { + continue; + } + + let rank = ClaimRank { + in_landing_window: Some(window_index) == active_window_index, + // A quake window starts hidden, so a pane in it is not one the user is + // about to look at. + in_visible_window: !window.quake_mode, + in_active_tab: tab_index == window.active_tab_index, + is_focused: leaf.is_focused, + observed_at: recorded.observed_at, + }; + + match winners.entry(recorded.session_id.as_str()) { + Entry::Vacant(entry) => { + entry.insert((rank, pane_uuid)); + } + Entry::Occupied(mut entry) => { + let (best_rank, best_uuid) = entry.get(); + // The uuid closes the ordering so that two panes the ranking cannot + // separate still resolve the same way on every launch. + if (rank, &pane_uuid.0) > (*best_rank, &best_uuid.0) { + entry.insert((rank, pane_uuid)); + } + } + } + } + } + } + + winners.into_values().map(|(_, uuid)| uuid).collect() +} + +/// Whether a pane restoring from `snapshot` could resume `recorded`, as far as a store read +/// before any window exists can tell. +/// +/// These are exactly the checks [`resume_eligibility`] makes that need no live pane, repeated +/// here so the claim is contested only by panes that might win something by it. Ownership itself +/// stays out: that is what this decides. +fn could_resume_from_snapshot( + snapshot: &TerminalPaneSnapshot, + recorded: &RecordedAgentSession, +) -> bool { + // A cwd reaches the snapshot only for a local session, and a pane Warp drives itself always + // snapshots an input config, so a pane missing either was never one to relaunch in. + let Some(cwd) = &snapshot.cwd else { + return false; + }; + snapshot.input_config.is_some() + && ResumeDeclarations::embedded().supports(recorded.agent) + // The pane comes up in its snapshot cwd when that directory still resolves, which is the + // same comparison the gate makes against the directory the pane actually reached. + && existing_directory(&recorded.directory) + .is_some_and(|recorded| existing_directory(Path::new(cwd)) == Some(recorded)) +} + +/// How strong a pane's claim to a recorded identifier is, ordered worst to best by field so that +/// the derived comparison reads as the tie-break itself. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct ClaimRank { + in_landing_window: bool, + in_visible_window: bool, + in_active_tab: bool, + is_focused: bool, + observed_at: NaiveDateTime, +} + +/// Collects the terminal leaves of a snapshot pane tree, paired with the leaf that holds them. +fn collect_terminal_leaves<'a>( + node: &'a PaneNodeSnapshot, + leaves: &mut Vec<(&'a LeafSnapshot, &'a TerminalPaneSnapshot)>, +) { + match node { + PaneNodeSnapshot::Leaf(leaf) => { + if let LeafContents::Terminal(terminal) = &leaf.contents { + leaves.push((leaf, terminal)); + } + } + PaneNodeSnapshot::Branch(branch) => { + for (_, child) in &branch.children { + collect_terminal_leaves(child, leaves); + } + } + } +} + +/// Whether the pane restoring from `snapshot` may resume the session recorded for it, or the +/// reason it may not. +/// +/// `restored_directory` is the directory the pane actually came up in, which is not the same +/// question as whether the recorded one still exists: a pane recorded in a worktree that was +/// deleted before the restart comes up in the fallback directory, and so does a pane whose +/// recorded directory survives but whose snapshot pointed elsewhere. It is passed already +/// verified — the caller only has a directory to come up in because it resolved one — so only the +/// recorded side is checked for existence here. +pub(crate) fn resume_eligibility<'a>( + agent_restore: &'a AgentSessionRestore, + pane_uuid: &PaneUuid, + snapshot: &TerminalPaneSnapshot, + restored_directory: Option<&Path>, +) -> Result<&'a RecordedAgentSession, ResumeIneligibility> { + let recorded = agent_restore + .sessions + .get(pane_uuid) + .ok_or(ResumeIneligibility::NoRecordedSession)?; + + if !agent_restore.is_startup_restore { + return Err(ResumeIneligibility::NotStartupRestore); + } + if recorded.session_id.is_empty() { + return Err(ResumeIneligibility::NoSessionIdentifier); + } + if !ResumeDeclarations::embedded().supports(recorded.agent) { + return Err(ResumeIneligibility::AgentNotDeclared); + } + // A pane Warp drives itself always snapshots an input config; the branches that save a pane + // viewing someone else's session leave it unset (`TerminalPane::snapshot`). + if snapshot.input_config.is_none() { + return Err(ResumeIneligibility::SharedSessionViewer); + } + // A cwd reaches the snapshot only for a local session (`pwd_if_local`), so a pane without + // one either ran elsewhere or never said where it ran; neither is a pane to relaunch in. + if snapshot.cwd.is_none() { + return Err(ResumeIneligibility::SessionNotLocal); + } + + let recorded_directory = existing_directory(&recorded.directory) + .ok_or(ResumeIneligibility::RecordedDirectoryMissing)?; + if restored_directory.map(canonical_directory) != Some(recorded_directory) { + return Err(ResumeIneligibility::RestoredElsewhere); + } + + // Checked last so that a pane which could not have resumed anyway does not take the + // identifier away from a pane that could: the claim is resolved before any window exists, + // where none of the above is knowable yet. + if !agent_restore.owns_recorded_identifier(pane_uuid) { + return Err(ResumeIneligibility::IdentifierClaimedByAnotherPane); + } + + Ok(recorded) +} + +/// The invocation that reattaches a pane to `recorded`, or `None` when the recording cannot +/// produce one. +/// +/// R22: the elevation the user chose rides along only while the observation behind it is recent. +/// A recording older than the window still resumes the conversation, just without it. +fn resume_invocation_for(recorded: &RecordedAgentSession) -> Option { + let posture = PermissionPosture::for_observation(recorded.observed_at, Utc::now().naive_utc()); + ResumeDeclarations::embedded().build_resume_command( + recorded.agent, + &recorded.session_id, + &recorded.flags, + posture, + ) +} + +/// `path` in the one spelling a directory comparison can use. Both sides go through this so that +/// two spellings of one directory — a symlinked temporary directory, `/tmp` against +/// `/private/tmp` — are not read as two directories. +fn canonical_directory(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// [`canonical_directory`] for a path whose existence is still an open question, which is the +/// recorded directory alone: it was written before the restart and may be gone by now. +fn existing_directory(path: &Path) -> Option { + path.is_dir().then(|| canonical_directory(path)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AIDocumentPaneVisibilityAction { /// Ensure the requested AI document pane is visible. @@ -1497,6 +1739,7 @@ impl PaneGroup { fn restore_pane_tree( root: PaneNodeSnapshot, block_lists: Arc>>, + agent_restore: AgentSessionRestore, resources: TerminalViewResources, ctx: &mut ViewContext, pane_contents: &mut HashMap>, @@ -1510,6 +1753,7 @@ impl PaneGroup { PaneNodeSnapshot::Leaf(leaf) => Self::restore_pane_leaf( leaf, block_lists, + agent_restore, resources, ctx, pane_contents, @@ -1541,6 +1785,7 @@ impl PaneGroup { match PaneGroup::restore_pane_tree( node, block_lists.clone(), + agent_restore.clone(), resources.clone(), ctx, pane_contents, @@ -1577,6 +1822,7 @@ impl PaneGroup { fn restore_pane_leaf( leaf: LeafSnapshot, block_lists: Arc>>, + agent_restore: AgentSessionRestore, resources: TerminalViewResources, ctx: &mut ViewContext, pane_contents: &mut HashMap>, @@ -1627,9 +1873,60 @@ impl PaneGroup { let startup_directory = terminal_snapshot .cwd + .as_ref() .map(PathBuf::from) .filter(|path| path.is_dir()); + // The verdict is decided here, where the directory the pane is about to come up + // in is known. Not asking for it at all while the feature is off keeps its + // directory resolution — a stat and a canonicalize per restored pane — off every + // launch that cannot act on the answer or report it. + let resume_verdict = FeatureFlag::AgentSessionResume.is_enabled().then(|| { + resume_eligibility( + &agent_restore, + &uuid, + &terminal_snapshot, + startup_directory.as_deref(), + ) + }); + let resume_command = match &resume_verdict { + None => None, + Some(Ok(recorded)) => { + log::info!( + "Restored pane can resume its recorded {:?} agent session", + recorded.agent + ); + resume_invocation_for(recorded) + } + // The ordinary outcome for every pane that was not running an agent, so + // reporting it would say nothing about this feature. + Some(Err(ResumeIneligibility::NoRecordedSession)) => None, + Some(Err(reason)) => { + log::info!("Restored pane will not resume an agent session: {reason:?}"); + None + } + }; + + // Every pane that carried recorded state reports what became of it, resumed or + // not: the rate and the reasons are the only view the rollout has of a feature + // that is silent by design. A pane with nothing recorded reports nothing — it + // was not running an agent, which says nothing about this. While the feature is + // off nothing is armed and, on the same flag, nothing is sent. + if let Some(resume_verdict) = &resume_verdict + && let Some(recorded) = agent_restore.sessions.get(&uuid) + && let Some(outcome) = + ResumeOutcome::for_verdict(resume_verdict, resume_command.is_some()) + { + send_telemetry_from_ctx!( + AgentSessionResumeTelemetryEvent::pane_restored( + recorded, + outcome, + Utc::now().naive_utc(), + ), + ctx + ); + } + // Filter conversation IDs to only include those that have task messages // and are not entirely passive (ignored suggestions). // This prevents showing the "Previous session" banner when there's nothing to restore @@ -1687,6 +1984,12 @@ impl PaneGroup { let terminal_view_id = terminal_view.id(); + if let Some(resume_command) = resume_command { + terminal_view.update(ctx, |view, _| { + view.arm_agent_session_resume(resume_command); + }); + } + let pane_data = TerminalPane::new( uuid.0, terminal_manager, @@ -3390,6 +3693,7 @@ impl PaneGroup { server_api: Arc, panes_layout: PanesLayout, block_lists: Arc>>, + agent_restore: AgentSessionRestore, model_event_sender: Option>, ctx: &mut ViewContext, ) -> Self { @@ -3424,6 +3728,7 @@ impl PaneGroup { let result = Self::restore_pane_tree( *panes_snapshot, block_lists, + agent_restore, resources.clone(), ctx, pane_contents, diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 95de5d394ae..8c4427810a0 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::mpsc::Receiver; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; use ai::project_context::model::ProjectContextModel; @@ -11,12 +12,15 @@ use persistence::model::{ use repo_metadata::RepoMetadataModel; use repo_metadata::repositories::DetectedRepositories; use repo_metadata::watcher::DirectoryWatcher; +use serde_json::Value; use session_sharing_protocol::common::SessionId; use shared_session::permissions_manager::SessionPermissionsManager; use uuid::Uuid; use warp_core::features::FeatureFlag; +use warp_core::telemetry::TelemetryEvent as _; use warp_server_client::iap::IapManager; use warpui::platform::{WindowBounds, WindowStyle}; +use warpui::telemetry::EventPayload; use warpui::windowing::WindowManager; use warpui::windowing::state::ApplicationStage; use warpui::{App, ModelHandle}; @@ -29,6 +33,7 @@ use super::child_agent::{ HiddenChildAgentConversationRequest, HiddenChildAgentTaskContext, create_hidden_child_agent_conversation, }; +use super::telemetry::{AgentSessionResumeTelemetryEvent, RecordedAgeBucket, ResumeOutcome}; use super::*; use crate::ai::AIRequestUsageModel; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; @@ -84,13 +89,26 @@ use crate::settings::PrivacySettings; use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::system::SystemStats; +use crate::terminal::CLIAgent; use crate::terminal::alt_screen_reporting::AltScreenReporting; -use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::terminal::cli_agent_resume::{ + PERMISSION_POSTURE_FRESHNESS, RESUME_HISTORY_MARKER, RecordedFlag, +}; +use crate::terminal::cli_agent_sessions::event::parse_event; +use crate::terminal::cli_agent_sessions::{ + CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, + CLIAgentSessionsModel, +}; +use crate::terminal::event::{BlockCompletedEvent, BlockType, UserBlockCompleted}; +use crate::terminal::general_settings::GeneralSettings; use crate::terminal::history::History; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::local_tty::TerminalManager; use crate::terminal::local_tty::spawner::PtySpawner; +use crate::terminal::model::block::{BlockId, SerializedBlock}; +use crate::terminal::model::terminal_model::BlockIndex; use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; +use crate::terminal::model_events::ModelEvent as TerminalModelEvent; use crate::terminal::resizable_data::ResizableData; use crate::terminal::shared_session::{ IsSharedSessionCreator, SharedSessionActionSource, SharedSessionScrollbackType, @@ -253,6 +271,7 @@ fn mock_pane_group(app: &mut App, options: MockOptions) -> ViewHandle ServerApiProvider::as_ref(ctx).get(), options.layout, block_lists, + AgentSessionRestore::default(), None, ctx, ) @@ -3203,6 +3222,7 @@ fn test_focused_pane_is_synchronized_with_application_focus() { ServerApiProvider::as_ref(ctx).get(), panes_layout, block_lists, + AgentSessionRestore::default(), None, ctx, ) @@ -3523,3 +3543,1904 @@ fn test_undo_close_keeps_a_file_pane_watching_its_file() { }); }); } + +// A resume can only be offered if the recorded map and the restored pane agree on the key. The +// map is keyed by pane uuid, so the pane the snapshot rebuilds has to report that same uuid. +#[test] +fn restored_terminal_pane_reports_the_uuid_its_recorded_session_is_keyed_by() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let pane_uuid = vec![7, 7, 7]; + let recorded = app_state::RecordedAgentSession { + agent: CLIAgent::Claude, + session_id: "session-1".to_owned(), + flags: vec![RecordedFlag { + name: "--model".to_owned(), + value: Some("opus".to_owned()), + }], + directory: PathBuf::from("/tmp/project"), + observed_at: chrono::NaiveDate::from_ymd_opt(2026, 8, 11) + .expect("date should be valid") + .and_hms_opt(9, 30, 0) + .expect("time should be valid"), + }; + let agent_restore = AgentSessionRestore { + sessions: Arc::new(HashMap::from([( + PaneUuid(pane_uuid.clone()), + recorded.clone(), + )])), + claimed_panes: Arc::new(HashSet::from([PaneUuid(pane_uuid.clone())])), + is_startup_restore: true, + }; + + let layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot { + is_focused: true, + custom_vertical_tabs_title: None, + contents: LeafContents::Terminal(TerminalPaneSnapshot { + uuid: pane_uuid, + cwd: None, + shell_launch_data: None, + is_active: true, + is_read_only: false, + input_config: None, + llm_model_override: None, + active_profile_id: None, + conversation_ids_to_restore: vec![], + active_conversation_id: None, + }), + }))); + + let tips_model = app.add_model(|_| TipsCompleted::default()); + let restore_for_group = agent_restore.clone(); + let (_, pane_group) = app.add_window_with_bounds( + WindowStyle::NotStealFocus, + WindowBounds::ExactPosition(RectF::new(Vector2F::zero(), Vector2F::new(1024., 768.))), + |ctx| { + let banner_model_handle = ctx.add_model(|_| BannerState::default()); + PaneGroup::new_with_panes_layout( + tips_model, + banner_model_handle, + ServerApiProvider::as_ref(ctx).get(), + layout, + Arc::new(HashMap::new()), + restore_for_group, + None, + ctx, + ) + }, + ); + + let reported_uuid = pane_group.read(&app, |panes, _ctx| { + panes + .panes_of::() + .map(|pane| pane.session_uuid()) + .next() + .expect("the snapshot should have restored a terminal pane") + }); + + assert_eq!( + agent_restore.sessions.get(&PaneUuid(reported_uuid)), + Some(&recorded) + ); + }); +} + +/// A pane group whose panes report their persistence writes to the returned receiver, so a test +/// can read exactly what a pane asked the writer thread to store. +fn pane_group_reporting_model_events( + app: &mut App, +) -> (ViewHandle, Receiver) { + let (sender, receiver) = std::sync::mpsc::sync_channel(64); + let tips_model = app.add_model(|_| TipsCompleted::default()); + let (_, pane_group) = app.add_window_with_bounds( + WindowStyle::NotStealFocus, + WindowBounds::ExactPosition(RectF::new(Vector2F::zero(), Vector2F::new(1024., 768.))), + |ctx| { + let banner_model_handle = ctx.add_model(|_| BannerState::default()); + PaneGroup::new_with_panes_layout( + tips_model, + banner_model_handle, + ServerApiProvider::as_ref(ctx).get(), + Default::default(), + Arc::new(HashMap::new()), + AgentSessionRestore::default(), + Some(sender), + ctx, + ) + }, + ); + (pane_group, receiver) +} + +/// Starts a CLI agent session for `terminal_view_id`, as detection does once a recognized agent +/// has been the pane's foreground command for long enough. +fn start_cli_agent_session( + terminal_view_id: EntityId, + agent: CLIAgent, + ctx: &mut ViewContext, +) { + CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { + sessions.set_session( + terminal_view_id, + CLIAgentSession { + agent, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext::default(), + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + plugin_version: None, + remote_host: None, + draft_text: None, + custom_command_prefix: None, + received_rich_notification: false, + }, + ctx, + ); + }); +} + +/// Delivers the plugin event in which the agent reports `session_id`, the same shape the OSC 777 +/// listener parses out of the PTY. +fn report_agent_session_id( + terminal_view_id: EntityId, + session_id: &str, + ctx: &mut ViewContext, +) { + report_agent_event(terminal_view_id, "session_start", session_id, ctx); +} + +/// Delivers one of the agent's own lifecycle events. `tool_complete` is the one that fires once +/// per tool call, which is what makes an agent task a burst rather than a handful of events. +fn report_agent_event( + terminal_view_id: EntityId, + event: &str, + session_id: &str, + ctx: &mut ViewContext, +) { + let body = + format!(r#"{{"v":1,"agent":"claude","event":"{event}","session_id":"{session_id}"}}"#); + let event = parse_event(Some("warp://cli-agent"), &body).expect("the test event should parse"); + CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { + sessions.update_from_event(terminal_view_id, &event, ctx); + }); +} + +/// The capture writes the panes sent, oldest first. A `None` session is a pane reporting that it +/// has no agent left to resume. +fn captured_agent_session_writes( + events: &Receiver, +) -> Vec<(Vec, Option)> { + events + .try_iter() + .filter_map(|event| match event { + ModelEvent::SetAgentSession { pane_id, session } => Some((pane_id, session)), + _ => None, + }) + .collect() +} + +/// The identifiers the panes recorded, oldest first. +fn captured_agent_sessions(events: &Receiver) -> Vec<(Vec, String)> { + captured_agent_session_writes(events) + .into_iter() + .filter_map(|(pane_id, session)| Some((pane_id, session?.session_id))) + .collect() +} + +/// Completes a block in the pane's terminal, which is how the agent process exiting (a `User` +/// block) and the agent being suspended (a `Background` block) reach the sessions model. +fn complete_block_in_pane( + terminal_view: &ViewHandle, + block_type: BlockType, + ctx: &mut ViewContext, +) { + let dispatcher = terminal_view + .as_ref(ctx) + .model_event_dispatcher() + .to_owned(); + dispatcher.update(ctx, |_, ctx| { + ctx.emit(TerminalModelEvent::BlockCompleted(BlockCompletedEvent { + block_type, + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id: BlockId::new(), + session_id: None, + restored_block_was_local: None, + })); + }); +} + +/// The block a finished agent command leaves behind. +fn completed_user_block(command: &str) -> BlockType { + BlockType::User(UserBlockCompleted { + index: BlockIndex::zero(), + serialized_block: Arc::new(SerializedBlock::new_for_test( + command.as_bytes().to_vec(), + vec![], + )), + command: command.to_owned(), + command_with_obfuscated_secrets: command.to_owned(), + output_truncated: String::new(), + output_truncated_with_obfuscated_secrets: String::new(), + was_part_of_agent_interaction: false, + was_warp_authored: false, + started_at: None, + num_output_lines: 0, + num_output_lines_truncated: 0, + }) +} + +/// The uuid of the group's only terminal pane, which is the key its recorded state is stored +/// under. +fn only_terminal_pane_uuid(pane_group: &ViewHandle, app: &App) -> Vec { + pane_group.read(app, |panes, _ctx| { + panes + .panes_of::() + .map(|pane| pane.session_uuid()) + .next() + .expect("the group should hold one terminal pane") + }) +} + +/// The id of the group's first terminal pane, for tests that add a second one afterwards. +fn first_terminal_pane_id(pane_group: &ViewHandle, app: &App) -> PaneId { + pane_group.read(app, |panes, _ctx| { + panes + .terminal_pane_ids() + .next() + .expect("the group should hold a terminal pane") + }) +} + +/// The uuid recorded state is keyed to for the terminal pane with `pane_id`. +fn terminal_pane_uuid(pane_group: &ViewHandle, pane_id: PaneId, app: &App) -> Vec { + pane_group.read(app, |panes, _ctx| { + panes + .terminal_session_by_id(pane_id) + .expect("the group should hold a terminal pane with that id") + .session_uuid() + }) +} + +// AE1/R2: a pane whose agent reports a second identifier has to persist the second one, and the +// writes have to reach the writer in the order they were observed — an older identifier landing +// after a newer one would resume the wrong conversation. +#[test] +fn pane_records_each_identifier_its_agent_reports_in_order() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let pane_uuid = only_terminal_pane_uuid(&pane_group, &app); + + let terminal_view_id = pane_group.update(&mut app, |panes, ctx| { + let terminal_view_id = panes + .active_session_view(ctx) + .expect("the group should have an active terminal view") + .id(); + start_cli_agent_session(terminal_view_id, CLIAgent::Claude, ctx); + terminal_view_id + }); + + pane_group.update(&mut app, |_, ctx| { + report_agent_session_id(terminal_view_id, "first-identifier", ctx); + }); + pane_group.update(&mut app, |_, ctx| { + report_agent_session_id(terminal_view_id, "second-identifier", ctx); + }); + + assert_eq!( + captured_agent_sessions(&model_events), + vec![ + (pane_uuid.clone(), "first-identifier".to_owned()), + (pane_uuid, "second-identifier".to_owned()), + ], + "both identifiers must reach the writer in the order the agent reported them" + ); + }); +} + +/// Starts an agent in the group's terminal pane and has it report `session_id`, leaving one +/// recorded write behind. Returns the pane's terminal view. +fn record_agent_session_in_pane( + pane_group: &ViewHandle, + session_id: &str, + app: &mut App, +) -> ViewHandle { + let terminal_view = pane_group.update(app, |panes, ctx| { + let terminal_view = panes + .active_session_view(ctx) + .expect("the group should have an active terminal view"); + start_cli_agent_session(terminal_view.id(), CLIAgent::Claude, ctx); + terminal_view + }); + let terminal_view_id = terminal_view.id(); + pane_group.update(app, |_, ctx| { + report_agent_session_id(terminal_view_id, session_id, ctx); + }); + terminal_view +} + +// AE2/R3: the agent process exiting completes the pane's user block, which ends the session while +// the pane stays attached. That pane has nothing left to resume, and saying so is the only way +// the next launch does not offer a dead session. +#[test] +fn pane_whose_agent_exited_records_that_it_has_nothing_to_resume() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let pane_uuid = only_terminal_pane_uuid(&pane_group, &app); + let terminal_view = record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let _ = captured_agent_session_writes(&model_events); + + pane_group.update(&mut app, |_, ctx| { + complete_block_in_pane(&terminal_view, completed_user_block("claude"), ctx); + }); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![(pane_uuid, None)], + "an agent that ended in a live pane must leave that pane recording no session" + ); + }); +} + +// AE16/R21: suspending the agent completes a background block, which leaves the session — and the +// process — alive. The pane must keep what it recorded; the agent is still there to resume. +#[test] +fn pane_whose_agent_is_suspended_keeps_its_recorded_state() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let terminal_view = record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let recorded = captured_agent_sessions(&model_events); + assert_eq!( + recorded.len(), + 1, + "precondition: the pane recorded a session" + ); + + pane_group.update(&mut app, |_, ctx| { + complete_block_in_pane( + &terminal_view, + BlockType::Background(Arc::new(SerializedBlock::new_for_test( + b"claude".to_vec(), + vec![], + ))), + ctx, + ); + }); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "a suspended agent must not make the pane clear what it recorded" + ); + }); +} + +// AE15/R20: a pane hidden for a close the user can undo, and a pane torn down at app teardown, +// both end their CLI agent session on the way out. Neither says anything about the agent, which +// is still running — clearing there would erase exactly the state a restart needs. +#[test] +fn pane_detached_for_close_or_teardown_keeps_its_recorded_state() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let recorded = captured_agent_sessions(&model_events); + assert_eq!( + recorded.len(), + 1, + "precondition: the pane recorded a session" + ); + + // Closing the tab hides its panes so an undo can bring them back, and closing the window + // at teardown detaches every pane down the same path. + pane_group.update(&mut app, |panes, ctx| panes.detach_panes(ctx)); + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "a pane hidden for close must keep its recorded state so an undo (and the next \ + launch) still finds the agent it was running" + ); + }); +} + +// AE15/R20: the undo that the hide-for-close exists for. The pane comes back under the uuid its +// recorded state is keyed to, and nothing on the way out or the way back said that state was +// stale, so the next launch still resumes it. +#[test] +fn undone_close_leaves_the_pane_still_owning_its_recorded_state() { + let _undo_closed_panes = FeatureFlag::UndoClosedPanes.override_enabled(true); + + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let recorded = captured_agent_sessions(&model_events); + assert_eq!( + recorded.len(), + 1, + "precondition: the pane recorded a session" + ); + let agent_pane_id = first_terminal_pane_id(&pane_group, &app); + let pane_uuid = terminal_pane_uuid(&pane_group, agent_pane_id, &app); + + // A second pane, so closing the agent's pane hides it rather than emptying the group. + pane_group.update(&mut app, |panes, ctx| { + panes.add_terminal_pane(Direction::Right, None, ctx); + }); + pane_group.update(&mut app, |panes, ctx| panes.close_pane(agent_pane_id, ctx)); + assert!( + pane_group.read(&app, |panes, _ctx| panes + .is_pane_hidden_for_close(agent_pane_id)), + "precondition: the close hid the pane instead of removing it" + ); + + assert!( + pane_group.update(&mut app, |panes, ctx| panes + .restore_closed_pane(agent_pane_id, ctx)), + "the hidden pane should restore" + ); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "an undone close must leave the recorded state exactly as the pane left it" + ); + assert_eq!( + terminal_pane_uuid(&pane_group, agent_pane_id, &app), + pane_uuid, + "the restored pane is the same pane, so it still owns the row keyed to its uuid" + ); + }); +} + +// R20/KTD13: once the undo window has passed, the stack discards the closed item and detaches its +// panes as permanently closed. Nothing brings that pane back, so the row keyed to its uuid is +// garbage whatever its agent was doing, and the same hook that drops its blocks drops it too. +#[test] +fn permanently_removed_pane_has_its_recorded_state_cleared() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let pane_uuid = only_terminal_pane_uuid(&pane_group, &app); + record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let recorded = captured_agent_sessions(&model_events); + assert_eq!( + recorded.len(), + 1, + "precondition: the pane recorded a session" + ); + + // What the undo stack runs when it discards a closed tab for good. + pane_group.update(&mut app, |panes, ctx| panes.clean_up_panes(ctx)); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![(pane_uuid, None)], + "a pane that is gone for good must not leave a row behind for a launch to resume" + ); + }); +} + +// A move detaches the pane from the group it is leaving, but the pane, its uuid and its running +// agent all survive into the destination. Clearing there would lose the state mid-drag. +#[test] +fn pane_moved_out_of_its_group_keeps_its_recorded_state() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let recorded = captured_agent_sessions(&model_events); + assert_eq!( + recorded.len(), + 1, + "precondition: the pane recorded a session" + ); + let agent_pane_id = first_terminal_pane_id(&pane_group, &app); + + pane_group.update(&mut app, |panes, ctx| { + panes.add_terminal_pane(Direction::Right, None, ctx); + }); + let moved = pane_group.update(&mut app, |panes, ctx| { + panes.remove_pane_for_move(&agent_pane_id, ctx) + }); + assert!( + moved.is_some(), + "precondition: the pane was taken for a move" + ); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "a pane that only moved is still running its agent and must keep what it recorded" + ); + }); +} + +// KTD14: the session event fires once per tool call, so an agent task is a burst of observations +// of a row that has not changed. Only what changed is worth a write — the channel these go down +// is bounded and shared with a sender that blocks the main thread when it fills. +#[test] +fn burst_of_tool_call_events_from_one_agent_collapses_to_one_write() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let terminal_view = record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let terminal_view_id = terminal_view.id(); + + for _ in 0..20 { + pane_group.update(&mut app, |_, ctx| { + report_agent_event(terminal_view_id, "tool_complete", "conversation-a", ctx); + }); + } + + assert_eq!( + captured_agent_sessions(&model_events) + .into_iter() + .map(|(_, session_id)| session_id) + .collect::>(), + vec!["conversation-a".to_owned()], + "an agent task's worth of tool calls must cost one write, not one per call" + ); + }); +} + +// Starting a second agent in the same pane ends the first session with the second one already +// registered. The pane is still running an agent, so it has something to resume and must not +// record that it has nothing. +#[test] +fn pane_that_replaced_its_agent_does_not_record_an_absent_session() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let terminal_view = record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + let terminal_view_id = terminal_view.id(); + let _ = captured_agent_session_writes(&model_events); + + pane_group.update(&mut app, |_, ctx| { + start_cli_agent_session(terminal_view_id, CLIAgent::Codex, ctx); + }); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "a pane that swapped one agent for another still has an agent to resume" + ); + }); +} + +// The eligibility gate reads a recorded directory that still resolves as the proof that the pane +// ran its agent here. A pane whose session is not local has no directory to report, and giving +// it one would let a session that never ran on this machine be relaunched on it. +#[test] +fn pane_without_a_local_directory_records_none_and_stays_ineligible() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let pane_uuid = only_terminal_pane_uuid(&pane_group, &app); + record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + + let writes = captured_agent_session_writes(&model_events); + let (_, session) = writes.first().expect("the pane should have recorded state"); + let session = session + .as_ref() + .expect("the recording should hold the reported session") + .clone(); + assert_eq!( + session.directory, + PathBuf::new(), + "a pane that reports no local working directory must record no directory" + ); + + let restore = startup_restore_for_test( + [(PaneUuid(pane_uuid.clone()), session)], + [PaneUuid(pane_uuid.clone())], + ); + assert_eq!( + resume_eligibility( + &restore, + &PaneUuid(pane_uuid.clone()), + &local_pane_snapshot_for_test(&pane_uuid, Some(Path::new("/tmp"))), + Some(Path::new("/tmp")), + ), + Err(ResumeIneligibility::RecordedDirectoryMissing), + "a recording without a directory must not pass the gate that treats one as proof the \ + agent ran here" + ); + }); +} + +// The capture is part of session restore, so a user who turned session restore off has nothing +// recorded about their agents at all. +#[test] +fn pane_records_nothing_when_session_restore_is_off() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + GeneralSettings::handle(&app).update(&mut app, |settings, ctx| { + settings + .restore_session + .set_value(false, ctx) + .expect("the setting should be writable in tests"); + }); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let terminal_view = record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + + pane_group.update(&mut app, |_, ctx| { + complete_block_in_pane(&terminal_view, completed_user_block("claude"), ctx); + }); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "with session restore off, a pane records neither its agent nor its absence" + ); + }); +} + +// The capture is the feature, not a preparation for it: with the flag off nothing can act on what +// a pane records, and writing the agent, identifier, flags and directory of every pane for a +// disabled feature is state the user never opted into. +#[test] +fn pane_records_nothing_while_the_feature_is_off() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(false); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + let terminal_view = record_agent_session_in_pane(&pane_group, "conversation-a", &mut app); + + pane_group.update(&mut app, |_, ctx| { + complete_block_in_pane(&terminal_view, completed_user_block("claude"), ctx); + }); + // The permanent-close path writes on its own, and it is behind the same flag. + pane_group.update(&mut app, |panes, ctx| panes.clean_up_panes(ctx)); + + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "with the feature off, a pane records neither its agent nor its absence" + ); + }); +} + +// R19: the persisted value is a purpose-built struct, and the session context it is derived from +// carries the user's prompts, the agent's replies, its summaries and its tool previews. None of +// that may reach the store, so this pins the recorded field set exhaustively and checks each +// field against a context stuffed with every sensitive value the model can hold. +#[test] +fn recorded_agent_session_carries_no_prompt_response_summary_or_tool_preview() { + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + let (pane_group, model_events) = pane_group_reporting_model_events(&mut app); + + let terminal_view_id = pane_group.update(&mut app, |panes, ctx| { + let terminal_view_id = panes + .active_session_view(ctx) + .expect("the group should have an active terminal view") + .id(); + CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { + sessions.set_session( + terminal_view_id, + CLIAgentSession { + agent: CLIAgent::Claude, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext { + cwd: Some("SENSITIVE-cwd".to_owned()), + project: Some("SENSITIVE-project".to_owned()), + session_id: Some("conversation-a".to_owned()), + tool_name: Some("SENSITIVE-tool-name".to_owned()), + tool_input_preview: Some("SENSITIVE-tool-preview".to_owned()), + summary: Some("SENSITIVE-summary".to_owned()), + query: Some("SENSITIVE-prompt".to_owned()), + response: Some("SENSITIVE-response".to_owned()), + }, + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + plugin_version: None, + remote_host: None, + draft_text: Some("SENSITIVE-draft".to_owned()), + custom_command_prefix: None, + received_rich_notification: false, + }, + ctx, + ); + }); + terminal_view_id + }); + pane_group.update(&mut app, |_, ctx| { + report_agent_session_id(terminal_view_id, "conversation-a", ctx); + }); + + let writes = captured_agent_session_writes(&model_events); + let (_, session) = writes.first().expect("the pane should have recorded state"); + let session = session + .as_ref() + .expect("the recording should hold the reported session"); + // Destructured exhaustively on purpose: a field added to the recorded state has to be + // looked at here before it can be persisted. + let RecordedAgentSession { + agent, + session_id, + flags, + directory, + observed_at, + } = session; + let persisted = format!( + "{agent:?} {session_id} {flags:?} {} {observed_at}", + directory.display() + ); + assert!( + !persisted.contains("SENSITIVE"), + "the recorded state must carry nothing of the session context but the identifier, \ + got: {persisted}" + ); + assert_eq!(session_id, "conversation-a"); + }); +} + +/// A recording for a pane that was running Claude in `directory` under `session_id`. +fn recorded_session_for_test(session_id: &str, directory: &Path) -> RecordedAgentSession { + RecordedAgentSession { + agent: CLIAgent::Claude, + session_id: session_id.to_owned(), + flags: vec![], + directory: directory.to_path_buf(), + observed_at: chrono::NaiveDate::from_ymd_opt(2026, 8, 11) + .expect("date should be valid") + .and_hms_opt(9, 30, 0) + .expect("time should be valid"), + } +} + +/// A snapshot of a first-party local terminal pane that came up in `cwd`. A local pane always +/// carries an input config and a cwd; the branches that null either belong to panes the gate has +/// to reject. +fn local_pane_snapshot_for_test(uuid: &[u8], cwd: Option<&Path>) -> TerminalPaneSnapshot { + TerminalPaneSnapshot { + uuid: uuid.to_vec(), + cwd: cwd.map(|path| path.to_string_lossy().into_owned()), + shell_launch_data: None, + is_active: true, + is_read_only: false, + input_config: Some(InputConfig { + input_type: crate::ai::blocklist::InputType::Shell, + is_locked: false, + }), + llm_model_override: None, + active_profile_id: None, + conversation_ids_to_restore: vec![], + active_conversation_id: None, + } +} + +fn startup_restore_for_test( + sessions: impl IntoIterator, + claimed_panes: impl IntoIterator, +) -> AgentSessionRestore { + AgentSessionRestore { + sessions: Arc::new(sessions.into_iter().collect()), + claimed_panes: Arc::new(claimed_panes.into_iter().collect()), + is_startup_restore: true, + } +} + +/// A window snapshot holding `panes` in one tab, so claim resolution has a window layout to +/// reason about without a window existing. +fn window_snapshot_for_test(panes: Vec) -> WindowSnapshot { + WindowSnapshot { + tabs: vec![app_state::TabSnapshot { + custom_title: None, + root: PaneNodeSnapshot::Branch(BranchSnapshot { + direction: app_state::SplitDirection::Horizontal, + children: panes + .into_iter() + .map(|pane| { + ( + app_state::PaneFlex(1.), + PaneNodeSnapshot::Leaf(LeafSnapshot { + is_focused: false, + custom_vertical_tabs_title: None, + contents: LeafContents::Terminal(pane), + }), + ) + }) + .collect(), + }), + default_directory_color: None, + selected_color: Default::default(), + left_panel: None, + right_panel: None, + group_id: None, + pinned: false, + }], + active_tab_index: 0, + team_uid: None, + bounds: None, + fullscreen_state: Default::default(), + quake_mode: false, + universal_search_width: None, + warp_ai_width: None, + voltron_width: None, + warp_drive_index_width: None, + left_panel_open: false, + vertical_tabs_panel_open: false, + left_panel_width: None, + right_panel_width: None, + agent_management_filters: None, + tab_groups: vec![], + } +} + +// AE8: a pane recorded in a git worktree that was deleted before the restart restores as a plain +// shell. Resuming would run the agent in the fallback directory, which is not where it was. +#[test] +fn resume_is_ineligible_when_the_recorded_directory_no_longer_exists() { + let worktree = tempfile::tempdir().expect("temp dir"); + let recorded_directory = worktree.path().to_path_buf(); + let pane_uuid = PaneUuid(vec![1]); + let agent_restore = startup_restore_for_test( + [( + pane_uuid.clone(), + recorded_session_for_test("session-1", &recorded_directory), + )], + [pane_uuid.clone()], + ); + let snapshot = local_pane_snapshot_for_test(&pane_uuid.0, Some(&recorded_directory)); + worktree.close().expect("the worktree should be removable"); + + assert_eq!( + resume_eligibility(&agent_restore, &pane_uuid, &snapshot, None), + Err(ResumeIneligibility::RecordedDirectoryMissing) + ); +} + +// AE12: the recorded directory still resolves, but the pane's shell came up somewhere else, so +// the session belongs to a directory this pane is not in. +#[test] +fn resume_is_ineligible_when_the_pane_restored_into_another_directory() { + let recorded_directory = tempfile::tempdir().expect("temp dir"); + let restored_directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let agent_restore = startup_restore_for_test( + [( + pane_uuid.clone(), + recorded_session_for_test("session-1", recorded_directory.path()), + )], + [pane_uuid.clone()], + ); + let snapshot = local_pane_snapshot_for_test(&pane_uuid.0, Some(restored_directory.path())); + + assert_eq!( + resume_eligibility( + &agent_restore, + &pane_uuid, + &snapshot, + Some(restored_directory.path()) + ), + Err(ResumeIneligibility::RestoredElsewhere) + ); +} + +// A pane that came up in the directory its session was recorded in is the only shape that +// resumes, so the gate has to say yes to it. +#[test] +fn resume_is_eligible_when_the_pane_restored_into_its_recorded_directory() { + let directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let recorded = recorded_session_for_test("session-1", directory.path()); + let agent_restore = + startup_restore_for_test([(pane_uuid.clone(), recorded.clone())], [pane_uuid.clone()]); + let snapshot = local_pane_snapshot_for_test(&pane_uuid.0, Some(directory.path())); + + assert_eq!( + resume_eligibility( + &agent_restore, + &pane_uuid, + &snapshot, + Some(directory.path()) + ), + Ok(&recorded) + ); +} + +// AE9: two panes in different windows recorded one identifier. The claim is resolved over the +// whole store before any window is created — this test creates none — and it goes to the pane in +// the window the user lands in, which restore creates last. +#[test] +fn resume_claims_go_to_the_pane_in_the_window_the_user_lands_in() { + let directory = tempfile::tempdir().expect("temp dir"); + let background_pane = PaneUuid(vec![1]); + let landing_pane = PaneUuid(vec![2]); + let undisputed_pane = PaneUuid(vec![3]); + let sessions = HashMap::from([ + ( + background_pane.clone(), + recorded_session_for_test("shared", directory.path()), + ), + ( + landing_pane.clone(), + recorded_session_for_test("shared", directory.path()), + ), + ( + undisputed_pane.clone(), + recorded_session_for_test("its-own", directory.path()), + ), + ]); + let windows = vec![ + window_snapshot_for_test(vec![ + local_pane_snapshot_for_test(&background_pane.0, Some(directory.path())), + local_pane_snapshot_for_test(&undisputed_pane.0, Some(directory.path())), + ]), + window_snapshot_for_test(vec![local_pane_snapshot_for_test( + &landing_pane.0, + Some(directory.path()), + )]), + ]; + + let claims = resolve_agent_session_claims(&windows, Some(1), &sessions); + + assert_eq!( + claims, + HashSet::from([landing_pane.clone(), undisputed_pane.clone()]), + "the disputed identifier goes to the landing window, and an undisputed one is untouched" + ); + + // The landing window is not a position in the list: with the same layout landing elsewhere, + // the identifier follows the user rather than the restore order. + let claims = resolve_agent_session_claims(&windows, Some(0), &sessions); + + assert_eq!(claims, HashSet::from([background_pane, undisputed_pane])); +} + +// AE9: the claim is settled before a window exists, so a pane that could never resume must not +// take an identifier with it. Ranking it first and rejecting it later leaves the pane that would +// have resumed holding a lost claim, and neither comes back. +#[test] +fn a_pane_that_could_not_resume_does_not_win_a_claim_from_one_that_could() { + let directory = tempfile::tempdir().expect("temp dir"); + let eligible = PaneUuid(vec![1]); + let ineligible = PaneUuid(vec![2]); + let sessions = HashMap::from([ + ( + eligible.clone(), + recorded_session_for_test("shared", directory.path()), + ), + ( + ineligible.clone(), + RecordedAgentSession { + // Observed last, and last in a window that ranks every other way the same, so the + // tie-break would hand it the identifier. + observed_at: recorded_session_for_test("shared", directory.path()).observed_at + + chrono::Duration::hours(1), + ..recorded_session_for_test("shared", &directory.path().join("gone")) + }, + ), + ]); + let windows = vec![window_snapshot_for_test(vec![ + local_pane_snapshot_for_test(&eligible.0, Some(directory.path())), + local_pane_snapshot_for_test(&ineligible.0, Some(directory.path())), + ])]; + + assert_eq!( + resolve_agent_session_claims(&windows, Some(0), &sessions), + HashSet::from([eligible]) + ); +} + +// AE9: exactly one pane resumes per identifier, so the pane that lost the claim is ineligible +// even though everything about the pane itself is fine. +#[test] +fn resume_is_ineligible_for_the_pane_that_lost_a_duplicated_identifier() { + let directory = tempfile::tempdir().expect("temp dir"); + let losing_pane = PaneUuid(vec![1]); + let winning_pane = PaneUuid(vec![2]); + let agent_restore = startup_restore_for_test( + [ + ( + losing_pane.clone(), + recorded_session_for_test("shared", directory.path()), + ), + ( + winning_pane.clone(), + recorded_session_for_test("shared", directory.path()), + ), + ], + [winning_pane.clone()], + ); + + assert_eq!( + resume_eligibility( + &agent_restore, + &losing_pane, + &local_pane_snapshot_for_test(&losing_pane.0, Some(directory.path())), + Some(directory.path()) + ), + Err(ResumeIneligibility::IdentifierClaimedByAnotherPane) + ); + assert!( + resume_eligibility( + &agent_restore, + &winning_pane, + &local_pane_snapshot_for_test(&winning_pane.0, Some(directory.path())), + Some(directory.path()) + ) + .is_ok(), + "the pane that won the identifier still resumes" + ); +} + +// AE4: a pane running a recognized agent that never reported an identifier has nothing to +// reattach to, and Warp picks a session on no other basis. +#[test] +fn resume_is_ineligible_without_a_recorded_identifier() { + let directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let mut recorded = recorded_session_for_test("", directory.path()); + recorded.session_id = String::new(); + let agent_restore = + startup_restore_for_test([(pane_uuid.clone(), recorded)], [pane_uuid.clone()]); + + assert_eq!( + resume_eligibility( + &agent_restore, + &pane_uuid, + &local_pane_snapshot_for_test(&pane_uuid.0, Some(directory.path())), + Some(directory.path()) + ), + Err(ResumeIneligibility::NoSessionIdentifier) + ); +} + +// An agent with no resume declaration has no invocation that reattaches, so a recording for it +// can only be dropped. +#[test] +fn resume_is_ineligible_for_an_agent_without_a_resume_declaration() { + let directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let mut recorded = recorded_session_for_test("session-1", directory.path()); + recorded.agent = CLIAgent::Gemini; + let agent_restore = + startup_restore_for_test([(pane_uuid.clone(), recorded)], [pane_uuid.clone()]); + + assert_eq!( + resume_eligibility( + &agent_restore, + &pane_uuid, + &local_pane_snapshot_for_test(&pane_uuid.0, Some(directory.path())), + Some(directory.path()) + ), + Err(ResumeIneligibility::AgentNotDeclared) + ); +} + +// R16: a pane whose session ran over SSH restores as a local shell, where the recorded +// identifier means nothing. The save path proves locality by writing a cwd only for a local +// session, so a snapshot without one is not a pane to resume in. +#[test] +fn resume_is_ineligible_for_a_pane_whose_session_was_not_local() { + let directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let agent_restore = startup_restore_for_test( + [( + pane_uuid.clone(), + recorded_session_for_test("session-1", directory.path()), + )], + [pane_uuid.clone()], + ); + + assert_eq!( + resume_eligibility( + &agent_restore, + &pane_uuid, + &local_pane_snapshot_for_test(&pane_uuid.0, None), + None + ), + Err(ResumeIneligibility::SessionNotLocal) + ); +} + +// A viewer of someone else's shared session never ran the agent locally. Its snapshot is written +// by the viewer branch, which carries no input config. +#[test] +fn resume_is_ineligible_for_a_shared_session_viewer_pane() { + let directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let agent_restore = startup_restore_for_test( + [( + pane_uuid.clone(), + recorded_session_for_test("session-1", directory.path()), + )], + [pane_uuid.clone()], + ); + let mut snapshot = local_pane_snapshot_for_test(&pane_uuid.0, Some(directory.path())); + snapshot.input_config = None; + + assert_eq!( + resume_eligibility( + &agent_restore, + &pane_uuid, + &snapshot, + Some(directory.path()) + ), + Err(ResumeIneligibility::SharedSessionViewer) + ); +} + +// A tab restored from a snapshot mid-session reaches the same restore path as startup. Resuming +// there would relaunch an agent the user never lost. +#[test] +fn resume_is_ineligible_outside_the_startup_restore_pass() { + let directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let agent_restore = AgentSessionRestore { + is_startup_restore: false, + ..startup_restore_for_test( + [( + pane_uuid.clone(), + recorded_session_for_test("session-1", directory.path()), + )], + [pane_uuid.clone()], + ) + }; + + assert_eq!( + resume_eligibility( + &agent_restore, + &pane_uuid, + &local_pane_snapshot_for_test(&pane_uuid.0, Some(directory.path())), + Some(directory.path()) + ), + Err(ResumeIneligibility::NotStartupRestore) + ); +} + +// U8 reports why a resume did not happen, which is only worth reporting if each rejection is its +// own reason: a gate that answered with one "not eligible" would make every cause look alike. +#[test] +fn every_resume_rejection_carries_its_own_reason() { + let directory = tempfile::tempdir().expect("temp dir"); + let pane_uuid = PaneUuid(vec![1]); + let unrecorded_pane = PaneUuid(vec![9]); + let recorded = recorded_session_for_test("session-1", directory.path()); + let local_snapshot = local_pane_snapshot_for_test(&pane_uuid.0, Some(directory.path())); + + let mut viewer_snapshot = local_snapshot.clone(); + viewer_snapshot.input_config = None; + let mut without_identifier = recorded.clone(); + without_identifier.session_id = String::new(); + let mut undeclared_agent = recorded.clone(); + undeclared_agent.agent = CLIAgent::Gemini; + let missing_directory = recorded_session_for_test("session-1", &directory.path().join("gone")); + + let claimed = + startup_restore_for_test([(pane_uuid.clone(), recorded.clone())], [pane_uuid.clone()]); + let reject = |restore: &AgentSessionRestore, + pane: &PaneUuid, + snapshot: &TerminalPaneSnapshot, + restored_directory: Option<&Path>| { + resume_eligibility(restore, pane, snapshot, restored_directory) + .expect_err("every case here should be rejected") + }; + + let reasons = vec![ + reject(&claimed, &unrecorded_pane, &local_snapshot, None), + reject( + &AgentSessionRestore { + is_startup_restore: false, + ..claimed.clone() + }, + &pane_uuid, + &local_snapshot, + Some(directory.path()), + ), + reject( + &startup_restore_for_test( + [(pane_uuid.clone(), without_identifier)], + [pane_uuid.clone()], + ), + &pane_uuid, + &local_snapshot, + Some(directory.path()), + ), + reject( + &startup_restore_for_test([(pane_uuid.clone(), undeclared_agent)], [pane_uuid.clone()]), + &pane_uuid, + &local_snapshot, + Some(directory.path()), + ), + reject( + &claimed, + &pane_uuid, + &viewer_snapshot, + Some(directory.path()), + ), + reject( + &claimed, + &pane_uuid, + &local_pane_snapshot_for_test(&pane_uuid.0, None), + None, + ), + reject( + &startup_restore_for_test( + [(pane_uuid.clone(), missing_directory)], + [pane_uuid.clone()], + ), + &pane_uuid, + &local_snapshot, + Some(directory.path()), + ), + reject(&claimed, &pane_uuid, &local_snapshot, None), + reject( + &startup_restore_for_test([(pane_uuid.clone(), recorded)], []), + &pane_uuid, + &local_snapshot, + Some(directory.path()), + ), + ]; + + let distinct: HashSet = reasons.iter().copied().collect(); + assert_eq!( + distinct.len(), + reasons.len(), + "each rejection should report a different reason, got {reasons:?}" + ); +} + +// R10: an ineligible pane restores exactly as it does today. Nothing about the restored pane may +// hint that a session was skipped, so the pane tree has to come back the way it does with no +// recording at all. +#[test] +fn an_ineligible_recorded_session_restores_the_pane_unchanged() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let pane_uuid = vec![4, 2]; + let agent_restore = startup_restore_for_test( + [( + PaneUuid(pane_uuid.clone()), + // The pane snapshot carries no cwd and the recorded directory does not resolve, + // so this recording cannot produce a resume however it is read. + recorded_session_for_test("session-1", Path::new("/warp/no/such/directory")), + )], + [PaneUuid(pane_uuid.clone())], + ); + + let restored_panes = |app: &mut App, restore: AgentSessionRestore| { + let layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot { + is_focused: true, + custom_vertical_tabs_title: None, + contents: LeafContents::Terminal(local_pane_snapshot_for_test(&pane_uuid, None)), + }))); + let tips_model = app.add_model(|_| TipsCompleted::default()); + let (_, pane_group) = app.add_window_with_bounds( + WindowStyle::NotStealFocus, + WindowBounds::ExactPosition(RectF::new( + Vector2F::zero(), + Vector2F::new(1024., 768.), + )), + |ctx| { + let banner_model_handle = ctx.add_model(|_| BannerState::default()); + PaneGroup::new_with_panes_layout( + tips_model, + banner_model_handle, + ServerApiProvider::as_ref(ctx).get(), + layout, + Arc::new(HashMap::new()), + restore, + None, + ctx, + ) + }, + ); + pane_group.read(app, |panes, _ctx| { + panes + .panes_of::() + .map(|pane| pane.session_uuid()) + .collect::>() + }) + }; + + let with_ineligible_recording = restored_panes(&mut app, agent_restore); + let without_recording = restored_panes(&mut app, AgentSessionRestore::default()); + + assert_eq!(with_ineligible_recording, vec![pane_uuid]); + assert_eq!(with_ineligible_recording, without_recording); + }); +} + +/// Restores `panes` through the startup path with `restore` in force, and reports what each pane +/// came back with: its session uuid and the resume invocation armed for it. +fn restored_panes_with_armed_resume( + app: &mut App, + panes: Vec, + restore: AgentSessionRestore, +) -> Vec<(Vec, Option)> { + let children = panes + .into_iter() + .map(|pane| { + ( + app_state::PaneFlex(1.), + PaneNodeSnapshot::Leaf(LeafSnapshot { + is_focused: false, + custom_vertical_tabs_title: None, + contents: LeafContents::Terminal(pane), + }), + ) + }) + .collect(); + let layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Branch(BranchSnapshot { + direction: app_state::SplitDirection::Horizontal, + children, + }))); + + let tips_model = app.add_model(|_| TipsCompleted::default()); + let (_, pane_group) = app.add_window_with_bounds( + WindowStyle::NotStealFocus, + WindowBounds::ExactPosition(RectF::new(Vector2F::zero(), Vector2F::new(1024., 768.))), + |ctx| { + let banner_model_handle = ctx.add_model(|_| BannerState::default()); + PaneGroup::new_with_panes_layout( + tips_model, + banner_model_handle, + ServerApiProvider::as_ref(ctx).get(), + layout, + Arc::new(HashMap::new()), + restore, + None, + ctx, + ) + }, + ); + + let mut restored: Vec<_> = pane_group.read(app, |panes, ctx| { + panes + .panes_of::() + .map(|pane| { + let armed = pane.terminal_view(ctx).read(ctx, |view, _| { + view.armed_agent_session_resume().map(str::to_owned) + }); + (pane.session_uuid(), armed) + }) + .collect() + }); + // Sorted by uuid: the restore walks the tree in whatever order it likes, and the question + // here is which pane got which invocation, not which pane was built first. + restored.sort_by(|(left, _), (right, _)| left.cmp(right)); + restored +} + +/// AE7/R6: every eligible pane comes back carrying its own invocation, in the ordinary startup +/// restore, with no per-tab step and nothing for the user to do. +#[test] +fn every_eligible_restored_pane_carries_its_own_resume_invocation() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().to_path_buf(); + + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + + let first = PaneUuid(vec![1]); + let second = PaneUuid(vec![2]); + let restore = startup_restore_for_test( + [ + (first.clone(), recorded_session_for_test("session-1", &path)), + ( + second.clone(), + recorded_session_for_test("session-2", &path), + ), + ], + [first.clone(), second.clone()], + ); + + let restored = restored_panes_with_armed_resume( + &mut app, + vec![ + local_pane_snapshot_for_test(&first.0, Some(&path)), + local_pane_snapshot_for_test(&second.0, Some(&path)), + ], + restore, + ); + + assert_eq!( + restored, + vec![ + ( + first.0.clone(), + Some(format!( + "claude --resume 'session-1' # {RESUME_HISTORY_MARKER}" + )) + ), + ( + second.0.clone(), + Some(format!( + "claude --resume 'session-2' # {RESUME_HISTORY_MARKER}" + )) + ), + ] + ); + }); +} + +/// With the feature off, an eligible pane restores exactly as it does today: a bare shell. +#[test] +fn no_resume_is_armed_while_the_feature_is_off() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().to_path_buf(); + + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(false); + initialize_app(&mut app); + + let pane = PaneUuid(vec![7]); + let restore = startup_restore_for_test( + [(pane.clone(), recorded_session_for_test("session-1", &path))], + [pane.clone()], + ); + + let restored = restored_panes_with_armed_resume( + &mut app, + vec![local_pane_snapshot_for_test(&pane.0, Some(&path))], + restore, + ); + + assert_eq!(restored, vec![(pane.0.clone(), None)]); + }); +} + +/// R8: an eligible pane restores the same pane tree an unrecorded one does. The invocation is +/// something the pane runs on top of what it restored, not a different restore. +#[test] +fn an_eligible_pane_restores_the_same_way_an_unrecorded_one_does() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().to_path_buf(); + + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + + let pane = PaneUuid(vec![9]); + let snapshot = || vec![local_pane_snapshot_for_test(&pane.0, Some(&path))]; + + let with_resume = restored_panes_with_armed_resume( + &mut app, + snapshot(), + startup_restore_for_test( + [(pane.clone(), recorded_session_for_test("session-1", &path))], + [pane.clone()], + ), + ); + let without_recording = + restored_panes_with_armed_resume(&mut app, snapshot(), AgentSessionRestore::default()); + + assert_eq!( + with_resume.iter().map(|(uuid, _)| uuid).collect::>(), + without_recording + .iter() + .map(|(uuid, _)| uuid) + .collect::>(), + "the restored pane tree must not depend on whether a resume is armed" + ); + assert!(with_resume[0].1.is_some()); + assert!(without_recording[0].1.is_none()); + }); +} + +/// The instant the resume-reporting tests restore at. Ages are expressed against it rather than +/// against the clock, so a band boundary is a value the test states. +fn resume_report_now() -> NaiveDateTime { + chrono::NaiveDate::from_ymd_opt(2026, 8, 11) + .expect("date should be valid") + .and_hms_opt(12, 0, 0) + .expect("time should be valid") +} + +/// A recording of a Claude session last observed `age` before [`resume_report_now`]. +fn recorded_session_observed_ago(age: chrono::Duration) -> RecordedAgentSession { + RecordedAgentSession { + observed_at: resume_report_now() - age, + ..recorded_session_for_test("session-1", Path::new("/warp/recorded/directory")) + } +} + +/// The payload `outcome` produces for a pane whose state was observed `age` ago. +fn reported_resume_payload(age: chrono::Duration, outcome: ResumeOutcome) -> Value { + let recorded = recorded_session_observed_ago(age); + AgentSessionResumeTelemetryEvent::pane_restored(&recorded, outcome, resume_report_now()) + .payload() + .expect("a reported resume outcome should carry a payload") +} + +/// U8: the event the rollout gates count. A pane that came back with its session says so, and +/// says which agent it was running — the two values every other number is read against. +#[test] +fn a_resumed_pane_reports_its_agent_and_a_resumed_outcome() { + let recorded = recorded_session_observed_ago(chrono::Duration::minutes(20)); + let outcome = ResumeOutcome::for_verdict(&Ok(&recorded), true) + .expect("a pane that carried recorded state has an outcome to report"); + let event = + AgentSessionResumeTelemetryEvent::pane_restored(&recorded, outcome, resume_report_now()); + + assert_eq!(event.name(), "AgentSessionResume.PaneRestore.Outcome"); + assert_eq!( + event.payload(), + Some(serde_json::json!({ + "agent": "Claude", + "outcome": "resumed", + "permission_flags_carried": true, + "recorded_age": "up_to_1h", + })) + ); +} + +/// R21: a pane the gate cleared that still armed nothing is the failure the rollout reads as +/// "resume is not reliable". It must not arrive looking like a pane that was never eligible. +#[test] +fn a_resume_that_armed_nothing_reports_a_failed_outcome() { + let recorded = recorded_session_observed_ago(chrono::Duration::minutes(20)); + + assert_eq!( + ResumeOutcome::for_verdict(&Ok(&recorded), false), + Some(ResumeOutcome::Failed) + ); + assert_eq!( + reported_resume_payload(chrono::Duration::minutes(20), ResumeOutcome::Failed)["outcome"], + serde_json::json!("failed") + ); +} + +/// U8: the outcomes are what a dashboard groups by, so each rejection has to arrive as its own +/// value — except the one that means "this pane was never running an agent", which is every +/// ordinary pane and says nothing about this feature. +#[test] +fn every_resume_rejection_reports_its_own_outcome() { + // Spelled out rather than derived from the mapping under test: these strings are the wire + // values a dashboard groups by, and the match stops a rejection added later from quietly + // reaching the field without one. + let expected_outcome = |reason| match reason { + ResumeIneligibility::NoRecordedSession => None, + ResumeIneligibility::NotStartupRestore => Some("not_startup_restore"), + ResumeIneligibility::NoSessionIdentifier => Some("no_session_identifier"), + ResumeIneligibility::AgentNotDeclared => Some("agent_not_declared"), + ResumeIneligibility::SharedSessionViewer => Some("shared_session_viewer"), + ResumeIneligibility::SessionNotLocal => Some("session_not_local"), + ResumeIneligibility::RecordedDirectoryMissing => Some("recorded_directory_missing"), + ResumeIneligibility::RestoredElsewhere => Some("restored_elsewhere"), + ResumeIneligibility::IdentifierClaimedByAnotherPane => { + Some("identifier_claimed_by_another_pane") + } + }; + let reasons = [ + ResumeIneligibility::NoRecordedSession, + ResumeIneligibility::NotStartupRestore, + ResumeIneligibility::NoSessionIdentifier, + ResumeIneligibility::AgentNotDeclared, + ResumeIneligibility::SharedSessionViewer, + ResumeIneligibility::SessionNotLocal, + ResumeIneligibility::RecordedDirectoryMissing, + ResumeIneligibility::RestoredElsewhere, + ResumeIneligibility::IdentifierClaimedByAnotherPane, + ]; + + for reason in reasons { + let verdict: Result<&RecordedAgentSession, ResumeIneligibility> = Err(reason); + let reported = ResumeOutcome::for_verdict(&verdict, false).map(|outcome| { + serde_json::to_value(outcome).expect("an outcome should serialize as a plain value") + }); + + assert_eq!( + reported, + expected_outcome(reason).map(|expected| serde_json::json!(expected)), + "{reason:?} should report its own outcome" + ); + } + + let distinct: HashSet<&str> = reasons + .iter() + .filter_map(|reason| expected_outcome(*reason)) + .collect(); + assert_eq!( + distinct.len(), + reasons.len() - 1, + "every rejection but the ordinary one should have a value of its own" + ); +} + +/// R22: the elevation the user chose rides along only while the observation behind it is recent, +/// and whether it did is the half of the window's cost the age bands alone cannot show. +#[test] +fn a_resume_outside_the_freshness_window_reports_dropped_posture_flags() { + let window = chrono::Duration::from_std(PERMISSION_POSTURE_FRESHNESS) + .expect("the freshness window should fit a chrono duration"); + let carried = + |age, outcome| reported_resume_payload(age, outcome)["permission_flags_carried"].clone(); + + assert_eq!( + carried( + window - chrono::Duration::minutes(1), + ResumeOutcome::Resumed + ), + serde_json::json!(true) + ); + assert_eq!( + carried( + window + chrono::Duration::minutes(1), + ResumeOutcome::Resumed + ), + serde_json::json!(false), + "a recording older than the window resumes without the posture the user chose" + ); + assert_eq!( + carried(chrono::Duration::minutes(1), ResumeOutcome::Failed), + serde_json::json!(false), + "a pane that never launched carried nothing, however fresh its recording was" + ); +} + +/// U8: the bands R22's window is chosen from. They bracket the candidate windows, so the field +/// distribution answers what moving the window to 6 or 24 hours would cost — the provisional 12 +/// hours is a band edge rather than a band. +#[test] +fn a_resume_reports_the_recorded_age_in_bracketing_bands() { + let bands = [ + (chrono::Duration::minutes(2), "up_to_1h"), + (chrono::Duration::hours(1), "up_to_1h"), + (chrono::Duration::hours(3), "1h_to_6h"), + (chrono::Duration::hours(6), "1h_to_6h"), + (chrono::Duration::hours(9), "6h_to_12h"), + (chrono::Duration::hours(12), "6h_to_12h"), + (chrono::Duration::hours(18), "12h_to_24h"), + (chrono::Duration::hours(24), "12h_to_24h"), + (chrono::Duration::days(3), "1d_to_7d"), + (chrono::Duration::days(7), "1d_to_7d"), + (chrono::Duration::days(30), "over_7d"), + // A recording dated after the restart: the clock moved backwards, and no band can be + // claimed for an age nothing vouches for. + (chrono::Duration::minutes(-5), "unverifiable"), + ]; + + for (age, expected) in bands { + assert_eq!( + reported_resume_payload(age, ResumeOutcome::Resumed)["recorded_age"], + serde_json::json!(expected), + "state observed {age} before the restart belongs in {expected}" + ); + } + + // The window sits on the 6h_to_12h edge, which is what makes the bands readable as a cost: + // everything up to that edge is what carrying the posture flags currently covers. + assert_eq!( + RecordedAgeBucket::for_observation( + resume_report_now() + - chrono::Duration::from_std(PERMISSION_POSTURE_FRESHNESS) + .expect("the freshness window should fit a chrono duration"), + resume_report_now(), + ), + RecordedAgeBucket::SixToTwelveHours + ); +} + +/// R20: the event measures the feature without shipping any of what the user was doing. The +/// recorded state it is built from holds the session identifier, the flags off the user's own +/// command and a path on their disk, and none of the three may reach the payload. +#[test] +fn the_reported_resume_outcome_carries_nothing_of_the_session() { + let recorded = RecordedAgentSession { + agent: CLIAgent::Claude, + session_id: "SENSITIVE-session-id".to_owned(), + flags: vec![RecordedFlag { + name: "--SENSITIVE-flag".to_owned(), + value: Some("SENSITIVE-flag-value".to_owned()), + }], + directory: PathBuf::from("/SENSITIVE/directory"), + observed_at: resume_report_now() - chrono::Duration::hours(2), + }; + + let event = AgentSessionResumeTelemetryEvent::pane_restored( + &recorded, + ResumeOutcome::Resumed, + resume_report_now(), + ); + // Destructured exhaustively on purpose: a field added to the event has to be looked at here + // before it can be reported. + let AgentSessionResumeTelemetryEvent::PaneRestored { + agent, + outcome, + permission_flags_carried, + recorded_age, + } = &event; + let reported = format!("{agent:?} {outcome:?} {permission_flags_carried} {recorded_age:?}"); + let payload = event + .payload() + .expect("a reported resume outcome should carry a payload"); + let serialized = payload.to_string(); + + for rendering in [&reported, &serialized] { + assert!( + !rendering.contains("SENSITIVE"), + "the event must carry nothing of the recorded session, got: {rendering}" + ); + assert!( + !rendering.contains('/'), + "the event must carry no path, got: {rendering}" + ); + assert!( + !rendering.contains("--"), + "the event must carry no flag off the user's command, got: {rendering}" + ); + } + assert_eq!( + payload + .as_object() + .expect("the payload should be an object") + .keys() + .collect::>(), + vec![ + "agent", + "outcome", + "permission_flags_carried", + "recorded_age" + ], + "the payload is these four closed values and nothing else" + ); + assert!( + !event.contains_ugc(), + "nothing the user generated reaches this event" + ); +} + +/// The reporting is part of the feature, so it is behind the same flag: nothing about a restart +/// is measured where nothing about it is attempted. +#[test] +fn no_resume_outcome_is_reported_while_the_feature_is_off() { + let event = AgentSessionResumeTelemetryEvent::pane_restored( + &recorded_session_observed_ago(chrono::Duration::minutes(20)), + ResumeOutcome::Resumed, + resume_report_now(), + ); + + { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(false); + assert!( + !event.enablement_state().is_enabled(), + "the send path drops the event while the feature is off" + ); + } + + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + assert!(event.enablement_state().is_enabled()); +} + +/// Drains the resume outcomes recorded so far, waiting up to `wait` for `expected` of them: the +/// send hands the event to the background executor, so a drain taken the instant a pane restored +/// can be empty for reasons that have nothing to do with the pane. +async fn recorded_resume_outcomes( + expected: usize, + wait: std::time::Duration, +) -> Vec<(Option, bool)> { + let deadline = instant::Instant::now() + wait; + let mut recorded = Vec::new(); + loop { + recorded.extend( + warpui::telemetry::flush_events() + .into_iter() + .filter_map(|event| match event.payload { + EventPayload::NamedEvent { name, value, .. } + if name == "AgentSessionResume.PaneRestore.Outcome" => + { + Some((value, event.contains_ugc)) + } + _ => None, + }), + ); + if recorded.len() >= expected || instant::Instant::now() >= deadline { + return recorded; + } + warpui::r#async::Timer::after(std::time::Duration::from_millis(10)).await; + } +} + +/// U8: the restore path itself reports, once per pane that carried recorded state — the event is +/// not a helper the path could be wired up without. +#[test] +fn a_restored_pane_reports_its_resume_outcome() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().to_path_buf(); + + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(true); + initialize_app(&mut app); + warpui::telemetry::flush_events(); + + let resuming_pane = PaneUuid(vec![1]); + let ordinary_pane = PaneUuid(vec![2]); + let recorded = RecordedAgentSession { + // Observed as the restart happens, so the age band and the posture rule have a + // definite answer here rather than one that depends on when the test runs. + observed_at: Utc::now().naive_utc(), + ..recorded_session_for_test("session-1", &path) + }; + + let restored = restored_panes_with_armed_resume( + &mut app, + vec![ + local_pane_snapshot_for_test(&resuming_pane.0, Some(&path)), + local_pane_snapshot_for_test(&ordinary_pane.0, Some(&path)), + ], + startup_restore_for_test([(resuming_pane.clone(), recorded)], [resuming_pane.clone()]), + ); + assert!(restored[0].1.is_some(), "the recorded pane should resume"); + + let reported = recorded_resume_outcomes(1, std::time::Duration::from_secs(5)).await; + + assert_eq!( + reported.len(), + 1, + "the pane that carried recorded state should report once, got: {reported:?}" + ); + assert!( + recorded_resume_outcomes(1, std::time::Duration::from_millis(300)) + .await + .is_empty(), + "the pane that carried no recorded state was not running an agent and reports nothing" + ); + assert_eq!( + reported[0].0, + Some(serde_json::json!({ + "agent": "Claude", + "outcome": "resumed", + "permission_flags_carried": true, + "recorded_age": "up_to_1h", + })) + ); + assert!(!reported[0].1, "the event holds no user-generated content"); + }); +} + +/// With the feature off, a restart is not measured either: the pane restores as a bare shell and +/// says nothing about having been asked to resume. +#[test] +fn a_restored_pane_reports_no_resume_outcome_while_the_feature_is_off() { + let directory = tempfile::tempdir().expect("temp dir"); + let path = directory.path().to_path_buf(); + + App::test((), |mut app| async move { + let _resume_flag = FeatureFlag::AgentSessionResume.override_enabled(false); + initialize_app(&mut app); + warpui::telemetry::flush_events(); + + let pane = PaneUuid(vec![1]); + let restored = restored_panes_with_armed_resume( + &mut app, + vec![local_pane_snapshot_for_test(&pane.0, Some(&path))], + startup_restore_for_test( + [(pane.clone(), recorded_session_for_test("session-1", &path))], + [pane.clone()], + ), + ); + assert_eq!(restored[0].1, None, "nothing should have been armed"); + + let reported = recorded_resume_outcomes(1, std::time::Duration::from_millis(500)).await; + + assert!( + reported.is_empty(), + "the feature reports nothing while it is off, got: {reported:?}" + ); + }); +} diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index bfe2187a1f1..6e3b22567b0 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1,15 +1,22 @@ //! Implementation of terminal panes. -#[cfg(not(target_family = "wasm"))] use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::SyncSender; +use chrono::Utc; +use parking_lot::Mutex; #[cfg(not(target_family = "wasm"))] use session_sharing_protocol::sharer::SessionSourceType; +use smol_str::SmolStr; use url::Url; #[cfg(not(target_family = "wasm"))] use warp_cli::agent::Harness; use warp_core::execution_mode::AppExecutionMode; +use warp_core::features::FeatureFlag; use warp_errors::report_error; +use warp_util::path::EscapeChar; use warpui::{ AppContext, EntityId, ModelHandle, SingletonEntity, ViewContext, ViewHandle, WindowId, }; @@ -38,7 +45,9 @@ use crate::ai::blocklist::{apply_child_agent_model_override, prepare_local_oz_ch use crate::ai::conversation_utils; use crate::ai::llms::LLMPreferences; use crate::ai::orchestration::{RemoteChildLaunchConfig, prepare_remote_child_launch}; -use crate::app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}; +use crate::app_state::{ + AmbientAgentPaneSnapshot, LeafContents, RecordedAgentSession, TerminalPaneSnapshot, +}; use crate::code::buffer_location::LocalOrRemotePath; #[cfg(feature = "local_fs")] use crate::pane_group::CodeSource; @@ -51,7 +60,8 @@ use crate::persistence::{BlockCompleted, ModelEvent}; #[cfg(not(target_family = "wasm"))] use crate::server::server_api::ServerApiProvider; use crate::session_management::SessionNavigationData; -use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::terminal::cli_agent_resume::{RecordedFlag, ResumeDeclarations}; +use crate::terminal::cli_agent_sessions::{CLIAgentSessionsModel, CLIAgentSessionsModelEvent}; use crate::terminal::general_settings::GeneralSettings; #[cfg(not(target_family = "wasm"))] use crate::terminal::shared_session::SharedSessionSource; @@ -59,7 +69,7 @@ use crate::terminal::shared_session::manager::{Manager, ManagerEvent}; use crate::terminal::shared_session::role_change_modal::RoleChangeOpenSource; use crate::terminal::shared_session::{SharedSessionStatus, join_link}; use crate::terminal::view::Event; -use crate::terminal::{TerminalManager, TerminalView}; +use crate::terminal::{CLIAgent, TerminalManager, TerminalView}; use crate::view_components::ToastFlavor; use crate::workspace::sync_inputs::SyncedInputState; use crate::workspace::{PaneViewLocator, WorkspaceRegistry}; @@ -74,6 +84,26 @@ use crate::{ pub type TerminalPaneView = PaneView; +/// What a pane needs to remember between agent-session observations. Held behind an `Arc` because +/// the subscription that reads it outlives the detach it has to know about. +#[derive(Default)] +struct AgentCaptureState { + /// Whether the pane is currently attached to a live pane group. + /// + /// Detaching a pane ends its CLI agent session for every reason but a move, so the `Ended` + /// that follows a detach says nothing about the agent — which is still running, whether the + /// pane was hidden for a close the user can undo or the app is shutting down. + is_attached: AtomicBool, + + /// What the pane last asked the writer to store. + /// + /// The session event that drives capture fires once per tool call, so an agent task's worth + /// of observations is hundreds of writes of a row that has not changed. Only a first + /// observation or a change is worth sending; the rest are dropped here, before they reach a + /// bounded channel that a blocking main-thread sender shares. + last_sent: Mutex>, +} + /// Data kept for terminal panes. pub struct TerminalPane { model_event_sender: Option>, @@ -83,6 +113,9 @@ pub struct TerminalPane { pane_configuration: ModelHandle, + /// State of this pane's agent session capture, shared with the subscription that writes it. + agent_capture: Arc, + /// Defining `terminal_manager` before `view` means that `terminal_manager` /// gets dropped first (guaranteed by the language), which halts the event /// loop and avoids possible deadlocks during session cleanup. This is enforced @@ -159,6 +192,7 @@ impl TerminalPane { model_event_sender, uuid, pane_configuration, + agent_capture: Arc::new(AgentCaptureState::default()), view, } } @@ -204,6 +238,32 @@ impl TerminalPane { } } + /// Instructs the SQLite thread to drop whatever agent state was recorded for this session. + /// + /// Only for a pane that will never come back: a pane hidden for close returns if the user + /// undoes the close, and what it recorded is exactly what resumes its agent then (R20), so a + /// row is garbage only once nothing can claim it. + pub(in crate::pane_group) fn delete_recorded_agent_session(&self, ctx: &AppContext) { + if !FeatureFlag::AgentSessionResume.is_enabled() + || !AppExecutionMode::as_ref(ctx).can_save_session() + { + return; + } + + if let Some(sender) = &self.model_event_sender { + let model_event = ModelEvent::SetAgentSession { + pane_id: self.uuid.clone(), + session: None, + }; + if let Err(err) = sender.send(model_event) { + report_error!( + anyhow::Error::new(err).context("Error sending agent session deleted event"), + extra: { "terminal_id" => ?self.terminal_view(ctx).id() } + ); + } + } + } + pub fn session_navigation_data( &self, pane_group_id: EntityId, @@ -247,6 +307,7 @@ impl PaneContent for TerminalPane { // TODO(ben): As much as possible, logic from PaneGroup::add_session should go here. // This will simplify PaneGroup, especially when implementing pane management. let terminal_pane_id = self.terminal_pane_id(); + self.agent_capture.is_attached.store(true, Ordering::SeqCst); self.view .update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx)); @@ -275,6 +336,22 @@ impl PaneContent for TerminalPane { } let terminal_view_id = self.terminal_view(ctx).id(); + + // Recording the pane's agent session is scoped to this pane by filtering on the terminal + // view captured here, the same way the agent driver scopes its own session subscription: + // the sessions model is a singleton keyed by terminal view, and a group-wide identity map + // would have to be kept in step with every pane that moves between groups. + let agent_capture = self.agent_capture.clone(); + ctx.subscribe_to_model( + &CLIAgentSessionsModel::handle(ctx), + move |group, _, event, ctx| { + if event.terminal_view_id() != terminal_view_id { + return; + } + capture_agent_session(group, event, terminal_pane_id, &agent_capture, ctx); + }, + ); + let manager_model = Manager::handle(ctx); ctx.subscribe_to_model(&manager_model, move |group, model_handle, event, ctx| { if let ManagerEvent::JoinedSession { @@ -369,6 +446,12 @@ impl PaneContent for TerminalPane { detach_type: DetachType, ctx: &mut ViewContext, ) { + // Marked before anything below can end the CLI agent session, so the capture subscription + // reads a detach for what it is rather than as an agent that finished. + self.agent_capture + .is_attached + .store(false, Ordering::SeqCst); + if matches!(detach_type, DetachType::Closed) { // Only immediately clear conversations and delete blocks if the session is being // permanently closed. @@ -377,6 +460,9 @@ impl PaneContent for TerminalPane { .clear_conversations_for_terminal_surface(self.terminal_view(ctx).id(), ctx); }); self.delete_blocks(ctx); + // This detach is the one place that knows the pane will not return, so it is also + // where the row keyed to its uuid stops being state and starts being garbage. + self.delete_recorded_agent_session(ctx); } // Unsubscribe from all views in the pane stack. @@ -612,6 +698,244 @@ impl PaneContent for TerminalPane { } } +/// Records what the CLI agent sessions model now reports about this pane, or that it reports +/// nothing, so a restart can offer to resume the agent the pane was running. +/// +/// The write is sent from here rather than from a spawned task on purpose: two independently +/// scheduled sends have no order between them, and an older identifier landing after a newer one +/// would offer to resume a conversation the user has already left behind. The payload is a +/// handful of short strings and the writer coalesces per pane, so one ordered send is cheaper +/// than the block writes that already go through this channel. +fn capture_agent_session( + group: &PaneGroup, + event: &CLIAgentSessionsModelEvent, + terminal_pane_id: TerminalPaneId, + agent_capture: &AgentCaptureState, + ctx: &mut ViewContext, +) { + // A detached pane records nothing further and clears nothing: its agent is still running, + // and what it last recorded is exactly what the next launch needs (R20). `remove_session` + // fires on every detach but a move — including the hide-for-close an undo reverses — and app + // teardown detaches every pane before draining the writer. + if !agent_capture.is_attached.load(Ordering::SeqCst) { + return; + } + + // Nothing about a pane's agent is written for a feature nothing can act on, so a build with + // the flag off records exactly what it did before this existed. On top of that, the same gate + // block saving uses: a user who turned session restore off, or a Warp that is not an + // interactive app, has nothing recorded about their panes. + if !FeatureFlag::AgentSessionResume.is_enabled() + || !*GeneralSettings::as_ref(ctx).restore_session + || !AppExecutionMode::as_ref(ctx).can_save_session() + { + return; + } + + let Some(sender) = group.model_event_sender.clone() else { + return; + }; + let Some(pane_id) = group + .terminal_session_by_id(terminal_pane_id) + .map(TerminalPane::session_uuid) + else { + return; + }; + + let session = match event { + CLIAgentSessionsModelEvent::SessionUpdated { .. } + | CLIAgentSessionsModelEvent::StatusChanged { .. } => { + // Nothing to record until the agent has reported an identifier: a recording without + // one claims no session and resumes nothing. + let Some((terminal_view, agent, session_id)) = + reported_agent_identity(group, terminal_pane_id, event.terminal_view_id(), ctx) + else { + return; + }; + // These events fire once per tool call while the pair naming the conversation holds + // for the whole task, so the burst is settled on a map lookup rather than on the + // grid walk, alias resolution and working-directory stat that reading the rest of + // the state costs. + let unchanged_identity = agent_capture + .last_sent + .lock() + .as_ref() + .is_some_and(|sent| sent.agent == agent && sent.session_id == session_id); + if unchanged_identity { + return; + } + Some(observed_agent_session( + &terminal_view, + agent, + session_id, + ctx, + )) + } + CLIAgentSessionsModelEvent::Ended { .. } => { + // An agent replaced rather than removed — a second agent started in the same pane — + // ends the old session with the new one already registered, and the pane is still + // running an agent. Only a pane the model reports nothing for has nothing to resume. + if CLIAgentSessionsModel::as_ref(ctx) + .session(event.terminal_view_id()) + .is_some() + { + return; + } + None + } + // Neither says anything new about the conversation: the agent starting is followed by the + // identifier arriving on its own event, and opening or closing the pane's input is a UI + // state with no bearing on what a restart would reattach to. + CLIAgentSessionsModelEvent::Started { .. } + | CLIAgentSessionsModelEvent::InputSessionChanged { .. } => return, + }; + + let mut last_sent = agent_capture.last_sent.lock(); + if records_same_agent_session(last_sent.as_ref(), session.as_ref()) { + return; + } + *last_sent = session.clone(); + drop(last_sent); + + if let Err(err) = sender.send(ModelEvent::SetAgentSession { pane_id, session }) { + report_error!( + anyhow::Error::new(err).context("Error sending agent session event"), + extra: { "terminal_pane_id" => ?terminal_pane_id } + ); + } +} + +/// Whether two observations say the same thing about a pane's agent. +/// +/// `observed_at` is deliberately left out: it moves with every tool call, and rewriting a row only +/// to advance it is the write volume this comparison exists to remove. The cost is that the time +/// recorded is when the state was first seen rather than last seen, which only ever loosens the +/// last tie-break between two panes claiming one identifier. +fn records_same_agent_session( + last_sent: Option<&RecordedAgentSession>, + observed: Option<&RecordedAgentSession>, +) -> bool { + match (last_sent, observed) { + (None, None) => true, + (Some(last_sent), Some(observed)) => { + // Destructured so that a field added to the recorded state has to be considered here + // before a change to it can go unwritten. + let RecordedAgentSession { + agent, + session_id, + flags, + directory, + observed_at: _, + } = last_sent; + agent == &observed.agent + && session_id == &observed.session_id + && flags == &observed.flags + && directory == &observed.directory + } + _ => false, + } +} + +/// The agent and identifier `terminal_pane_id` is running, with the view they were reported for, +/// or `None` while its agent has reported no session identifier. +/// +/// Kept apart from [`observed_agent_session`] so that the pair naming the conversation — all a +/// repeat observation has to be compared on — can be read without paying for the rest. +fn reported_agent_identity( + group: &PaneGroup, + terminal_pane_id: TerminalPaneId, + terminal_view_id: EntityId, + ctx: &AppContext, +) -> Option<(ViewHandle, CLIAgent, String)> { + let terminal_view = group.terminal_view_from_pane_id(terminal_pane_id, ctx)?; + // A pane can push another terminal view over the one the agent is running in. The pushed + // view's command line and working directory are not the agent's, so there is nothing to + // record from it. + if terminal_view.id() != terminal_view_id { + return None; + } + let (agent, session_id) = CLIAgentSessionsModel::as_ref(ctx) + .reported_agent_session(terminal_view_id) + .map(|(agent, session_id)| (agent, session_id.to_owned()))?; + Some((terminal_view, agent, session_id)) +} + +/// The agent state to record for a pane whose agent reported `agent` and `session_id`. +fn observed_agent_session( + terminal_view: &ViewHandle, + agent: CLIAgent, + session_id: String, + ctx: &AppContext, +) -> RecordedAgentSession { + let view = terminal_view.as_ref(ctx); + // The model lock is held only long enough to copy the command text out. Resolving an alias + // reads the shell session model, and reaching for a second model with this one held is what + // the locking rule in `AGENTS.md` forbids. + let command = { + let model = view.model.lock(); + model + .block_list() + .active_block() + .command_with_secrets_obfuscated(false) + }; + + let shell_session = view + .active_block_session_id() + .and_then(|session_id| view.sessions_model().as_ref(ctx).get(session_id)); + let flags = recorded_resume_flags( + agent, + &command, + shell_session + .as_ref() + .map(|session| session.shell_family().escape_char()), + shell_session.as_ref().map(|session| session.aliases()), + ); + + RecordedAgentSession { + agent, + session_id, + flags, + // Only a local session has a directory to report here. A pane running its agent + // elsewhere records none rather than a remote path, which could resolve locally and make + // a session that was never local look like one that can be relaunched in place. + directory: view + .pwd_if_local(ctx) + .map(PathBuf::from) + .unwrap_or_default(), + observed_at: Utc::now().naive_utc(), + } +} + +/// The resume-relevant flags `command` gave `agent`, with the first word resolved through the +/// shell session's aliases so that a flag carried by an alias is recorded as one the user ran. +/// +/// `command` is the obfuscated form of the invocation, so a secret passed to the agent is +/// recorded as its placeholder. The placeholder fails the declared value shape when the resume +/// invocation is built, which drops the flag instead of replaying a wrong value. +/// +/// A command that does not resolve to `agent` contributes no flags at all: it is some other +/// program running in the pane, and its arguments were never the agent's. +fn recorded_resume_flags( + agent: CLIAgent, + command: &str, + escape_char: Option, + aliases: Option<&HashMap>, +) -> Vec { + let resolved = CLIAgent::resolve_command_aliases(command, escape_char, aliases); + if !agent.matches_command(&resolved, escape_char) { + return Vec::new(); + } + + // Tokenized the way the shell reads the line, so a quoted prompt stays one word. Splitting on + // whitespace would read the flag-shaped words inside `claude "use --permission-mode + // bypassPermissions"` as flags the user chose, and record an elevation they never asked for. + // A line that does not tokenize is one nothing here can account for, so it contributes none. + let Ok(words) = shell_words::split(&resolved) else { + return Vec::new(); + }; + ResumeDeclarations::embedded().extract_resume_flags(agent, words.get(1..).unwrap_or_default()) +} + fn retrieve_shared_session_link(manager: &Manager, terminal_view_id: &EntityId) -> Option { let Some(session_id) = manager.session_id(terminal_view_id) else { log::warn!("Failed to get join link args for updating browser url"); diff --git a/app/src/pane_group/pane/terminal_pane_tests.rs b/app/src/pane_group/pane/terminal_pane_tests.rs index 2ebd6ac6332..3d1f1a7d87a 100644 --- a/app/src/pane_group/pane/terminal_pane_tests.rs +++ b/app/src/pane_group/pane/terminal_pane_tests.rs @@ -1,5 +1,5 @@ -//! Tests for [`inherit_share_for_local_child`]. These verify the pure -//! branching independent of the PaneGroup dispatch code. +//! Tests for [`inherit_share_for_local_child`] and [`recorded_resume_flags`]. These verify the +//! pure branching independent of the PaneGroup dispatch code. use uuid::Uuid; @@ -17,6 +17,172 @@ fn ambient_source(task_id: Option<&str>) -> SharedSessionSource { SharedSessionSource::ambient_agent(task_id.map(str::to_owned)) } +/// The alias map a shell session reports, in the form detection reads it. +fn shell_aliases(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(name, value)| (SmolStr::from(*name), (*value).to_owned())) + .collect() +} + +fn flag(name: &str, value: Option<&str>) -> RecordedFlag { + RecordedFlag { + name: name.to_owned(), + value: value.map(str::to_owned), + } +} + +#[test] +fn recorded_flags_keep_the_allowlisted_flags_of_the_invocation() { + assert_eq!( + recorded_resume_flags( + CLIAgent::Claude, + "claude --model opus --dangerously-skip-permissions", + Some(EscapeChar::Backslash), + None, + ), + vec![ + flag("--model", Some("opus")), + flag("--dangerously-skip-permissions", None), + ] + ); +} + +// KTD5: what gets recorded is the flag set the user actually ran, and an alias is part of that +// invocation — resolvable only now, while the shell session that defines it is alive. +#[test] +fn recorded_flags_include_the_flags_an_alias_carries() { + let aliases = shell_aliases(&[("c", "claude --permission-mode plan")]); + + assert_eq!( + recorded_resume_flags( + CLIAgent::Claude, + "c --model opus", + Some(EscapeChar::Backslash), + Some(&aliases), + ), + vec![ + flag("--permission-mode", Some("plan")), + flag("--model", Some("opus")), + ], + "a flag the user only ever typed as an alias is still a flag their session was running \ + with" + ); +} + +// KTD5: a prompt is one argument however many flag-shaped words the user wrote inside it. Reading +// those as flags would record a permission posture nobody chose, and the resume would then launch +// at an elevation the user never asked for. +#[test] +fn recorded_flags_ignore_flag_shaped_words_inside_a_quoted_prompt() { + let prompts = [ + r#"claude "use --permission-mode bypassPermissions to fix this""#, + r#"claude "try --dangerously-skip-permissions if the tool call fails""#, + r#"claude 'and pass --permission-mode=bypassPermissions'"#, + ]; + + for command in prompts { + assert_eq!( + recorded_resume_flags(CLIAgent::Claude, command, Some(EscapeChar::Backslash), None), + Vec::new(), + "{command} chose no permission posture" + ); + } +} + +// The other half of the same rule: a flag the user really did pass is still recorded, and a word +// repeating it inside the prompt is not a second one. +#[test] +fn recorded_flags_keep_a_real_flag_that_precedes_a_quoted_prompt() { + assert_eq!( + recorded_resume_flags( + CLIAgent::Claude, + r#"claude --model opus "prompt --model sonnet""#, + Some(EscapeChar::Backslash), + None, + ), + vec![flag("--model", Some("opus"))] + ); +} + +// A line the shell's own quoting rules cannot account for — an unterminated quote — is one this +// cannot tokenize either, and guessing at it is how a prompt turns into a flag. +#[test] +fn recorded_flags_are_empty_for_a_command_that_does_not_tokenize() { + assert_eq!( + recorded_resume_flags( + CLIAgent::Claude, + r#"claude --model opus "unterminated"#, + Some(EscapeChar::Backslash), + None, + ), + Vec::new() + ); +} + +// The identifier can be reported by a plugin running inside something that is not the agent's own +// command line — a wrapper, or a pane whose foreground command has already moved on. Those +// arguments were never the agent's, so none of them are recorded. +#[test] +fn recorded_flags_are_empty_when_the_command_is_not_the_agent() { + assert_eq!( + recorded_resume_flags( + CLIAgent::Claude, + "git commit --model opus", + Some(EscapeChar::Backslash), + None, + ), + Vec::new() + ); +} + +// R19/KTD5: the capture reads the obfuscated command text, so a secret in the invocation is +// recorded as its placeholder. That is the intended degradation — the placeholder fails the +// declared value shape when the resume command is built, which drops the flag rather than +// passing a wrong value to the agent. +#[test] +fn recorded_flags_carry_the_obfuscated_placeholder_rather_than_a_secret() { + let recorded = recorded_resume_flags( + CLIAgent::Claude, + "claude --model ********", + Some(EscapeChar::Backslash), + None, + ); + + assert_eq!(recorded, vec![flag("--model", Some("********"))]); + assert!( + ResumeDeclarations::embedded() + .build_resume_command( + CLIAgent::Claude, + "session-1", + &recorded, + crate::terminal::cli_agent_resume::PermissionPosture::Carry, + ) + .is_some_and(|command| !command.contains('*')), + "an obfuscated value must be dropped when the invocation is built, not replayed" + ); +} + +// An agent Warp knows but has not declared resume support for records no flags: there is no +// allowlist to read them against, and a flag carried into an invocation nobody validated is +// exactly what KTD5 rules out. +#[test] +fn recorded_flags_are_empty_for_an_agent_without_resume_declarations() { + assert!( + !ResumeDeclarations::embedded().supports(CLIAgent::Gemini), + "precondition: Gemini declares no resume support" + ); + assert_eq!( + recorded_resume_flags( + CLIAgent::Gemini, + "gemini --model pro", + Some(EscapeChar::Backslash), + None, + ), + Vec::new() + ); +} + #[test] fn inherit_share_returns_no_when_host_is_not_sharing() { let result = inherit_share_for_local_child(None, new_task_id()); diff --git a/app/src/pane_group/telemetry.rs b/app/src/pane_group/telemetry.rs new file mode 100644 index 00000000000..5b548c66562 --- /dev/null +++ b/app/src/pane_group/telemetry.rs @@ -0,0 +1,248 @@ +//! What a restart's resume did, reported without reporting what the user was doing. +//! +//! One event per restored pane that carried recorded state. It is the only place the field +//! behavior of the feature becomes visible: whether resume works, which of the gate's rules the +//! misses trip on, and how old the recordings behind them were. R22's freshness window is a +//! provisional constant until this last part comes back from the field. +//! +//! The payload is four closed values — an agent kind, an outcome, a flag, an age band — and +//! nothing else may join them. The invocation the pane would have run, the flags recorded off +//! the user's own command, the session identifier and the directory are all things this feature +//! touches and none of them belongs in an event: a resume's diagnostics live in the `full:` arm +//! of a `safe_*` macro, where they stay on the machine that produced them. + +use std::time::Duration; + +use chrono::NaiveDateTime; +use serde::Serialize; +use serde_json::{Value, json}; +use strum_macros::{EnumDiscriminants, EnumIter}; +use warp_core::features::FeatureFlag; +use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc}; + +use crate::app_state::RecordedAgentSession; +use crate::pane_group::ResumeIneligibility; +use crate::server::telemetry::CLIAgentType; +use crate::terminal::cli_agent_resume::PermissionPosture; + +#[derive(Debug, EnumDiscriminants)] +#[strum_discriminants(derive(EnumIter))] +pub(crate) enum AgentSessionResumeTelemetryEvent { + /// A restored pane that carried recorded state reported what became of it. + PaneRestored { + agent: CLIAgentType, + outcome: ResumeOutcome, + /// Whether the resume ran at the permission posture the user's own invocation had. + /// True says the recording was recent enough for R22 to let its posture flags ride + /// along, not that the user had recorded any: which flags survived validation is a + /// question about one invocation, and belongs nowhere near an event. + permission_flags_carried: bool, + recorded_age: RecordedAgeBucket, + }, +} + +impl AgentSessionResumeTelemetryEvent { + /// What a pane holding `recorded` reports for `outcome`, judged at `now`. + pub(crate) fn pane_restored( + recorded: &RecordedAgentSession, + outcome: ResumeOutcome, + now: NaiveDateTime, + ) -> Self { + let posture = PermissionPosture::for_observation(recorded.observed_at, now); + Self::PaneRestored { + agent: recorded.agent.into(), + outcome, + // Only a pane that launched can have carried anything: for every other outcome the + // posture never got the chance to apply, however fresh the recording was. + permission_flags_carried: outcome == ResumeOutcome::Resumed + && posture == PermissionPosture::Carry, + recorded_age: RecordedAgeBucket::for_observation(recorded.observed_at, now), + } + } +} + +/// What became of one restored pane that carried recorded state. +/// +/// Every rejection keeps its own value. A single "not eligible" would say that resume did not +/// happen without saying which rule stopped it, and the rules fail for unrelated reasons: a +/// deleted worktree is a fact about the user's machine, an undeclared agent is a gap in Warp. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ResumeOutcome { + /// The pane came back with a resume invocation armed for it. + Resumed, + /// The pane was eligible, but nothing could be armed for it. + Failed, + NotStartupRestore, + NoSessionIdentifier, + AgentNotDeclared, + SharedSessionViewer, + SessionNotLocal, + RecordedDirectoryMissing, + RestoredElsewhere, + IdentifierClaimedByAnotherPane, +} + +impl ResumeOutcome { + /// The outcome a pane reports for the gate's `verdict`, having armed an invocation or not, or + /// `None` when the pane has nothing to say about this feature. + pub(crate) fn for_verdict( + verdict: &Result<&RecordedAgentSession, ResumeIneligibility>, + resume_armed: bool, + ) -> Option { + match verdict { + Ok(_) if resume_armed => Some(Self::Resumed), + // Cleared by the gate and still holding nothing to run: what was recorded did not + // survive validation, which is the one outcome that says the feature itself failed. + Ok(_) => Some(Self::Failed), + Err(reason) => Self::for_ineligibility(*reason), + } + } + + /// The outcome for a pane the gate turned down, or `None` for the rejection that is not + /// about this feature at all: a pane that was never running an agent, which is most of them. + fn for_ineligibility(reason: ResumeIneligibility) -> Option { + match reason { + ResumeIneligibility::NoRecordedSession => None, + ResumeIneligibility::NotStartupRestore => Some(Self::NotStartupRestore), + ResumeIneligibility::NoSessionIdentifier => Some(Self::NoSessionIdentifier), + ResumeIneligibility::AgentNotDeclared => Some(Self::AgentNotDeclared), + ResumeIneligibility::SharedSessionViewer => Some(Self::SharedSessionViewer), + ResumeIneligibility::SessionNotLocal => Some(Self::SessionNotLocal), + ResumeIneligibility::RecordedDirectoryMissing => Some(Self::RecordedDirectoryMissing), + ResumeIneligibility::RestoredElsewhere => Some(Self::RestoredElsewhere), + ResumeIneligibility::IdentifierClaimedByAnotherPane => { + Some(Self::IdentifierClaimedByAnotherPane) + } + } + } +} + +/// How old the recorded state was when the pane came back, in bands coarse enough that no value +/// is a fact about one user's day. +/// +/// The edges bracket the values R22's freshness window could take — the provisional twelve hours +/// is an edge rather than a band — so the field distribution answers what moving the window to +/// six or twenty-four hours would cost, instead of only how the current guess did. Each band is +/// closed at its upper edge, matching the rule the window itself is applied with, so everything +/// up to and including [`RecordedAgeBucket::SixToTwelveHours`] is exactly what carries the +/// posture flags today. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub(crate) enum RecordedAgeBucket { + #[serde(rename = "up_to_1h")] + UpToOneHour, + #[serde(rename = "1h_to_6h")] + OneToSixHours, + #[serde(rename = "6h_to_12h")] + SixToTwelveHours, + #[serde(rename = "12h_to_24h")] + TwelveToTwentyFourHours, + #[serde(rename = "1d_to_7d")] + OneToSevenDays, + #[serde(rename = "over_7d")] + OverSevenDays, + /// The recording is dated after the restart that read it, so the clock moved backwards + /// between the two and nothing here can vouch for an age. Kept as a band of its own rather + /// than folded into the oldest one, which would read as evidence for a shorter window. + #[serde(rename = "unverifiable")] + Unverifiable, +} + +/// The upper edge of each age band, in the order they are tried. +const AGE_BAND_EDGES: [(Duration, RecordedAgeBucket); 5] = [ + (Duration::from_secs(60 * 60), RecordedAgeBucket::UpToOneHour), + ( + Duration::from_secs(6 * 60 * 60), + RecordedAgeBucket::OneToSixHours, + ), + ( + Duration::from_secs(12 * 60 * 60), + RecordedAgeBucket::SixToTwelveHours, + ), + ( + Duration::from_secs(24 * 60 * 60), + RecordedAgeBucket::TwelveToTwentyFourHours, + ), + ( + Duration::from_secs(7 * 24 * 60 * 60), + RecordedAgeBucket::OneToSevenDays, + ), +]; + +impl RecordedAgeBucket { + /// The band state observed at `observed_at` falls in when the pane restores at `now`. + pub(crate) fn for_observation(observed_at: NaiveDateTime, now: NaiveDateTime) -> Self { + let Ok(age) = (now - observed_at).to_std() else { + return Self::Unverifiable; + }; + AGE_BAND_EDGES + .iter() + .find_map(|(edge, bucket)| (age <= *edge).then_some(*bucket)) + .unwrap_or(Self::OverSevenDays) + } +} + +impl TelemetryEvent for AgentSessionResumeTelemetryEvent { + fn name(&self) -> &'static str { + AgentSessionResumeTelemetryEventDiscriminants::from(self).name() + } + + fn payload(&self) -> Option { + match self { + Self::PaneRestored { + agent, + outcome, + permission_flags_carried, + recorded_age, + } => Some(json!({ + "agent": agent, + "outcome": outcome, + "permission_flags_carried": permission_flags_carried, + "recorded_age": recorded_age, + })), + } + } + + fn description(&self) -> &'static str { + AgentSessionResumeTelemetryEventDiscriminants::from(self).description() + } + + fn enablement_state(&self) -> EnablementState { + AgentSessionResumeTelemetryEventDiscriminants::from(self).enablement_state() + } + + fn contains_ugc(&self) -> bool { + match self { + Self::PaneRestored { .. } => false, + } + } + + fn event_descs() -> impl Iterator> { + warp_core::telemetry::enum_events::() + } +} + +impl TelemetryEventDesc for AgentSessionResumeTelemetryEventDiscriminants { + fn name(&self) -> &'static str { + match self { + Self::PaneRestored => "AgentSessionResume.PaneRestore.Outcome", + } + } + + fn description(&self) -> &'static str { + match self { + Self::PaneRestored => { + "A restored pane that had a recorded agent session reported whether it resumed, \ + and how old the recording was" + } + } + } + + fn enablement_state(&self) -> EnablementState { + match self { + Self::PaneRestored => EnablementState::Flag(FeatureFlag::AgentSessionResume), + } + } +} + +warp_core::register_telemetry_event!(AgentSessionResumeTelemetryEvent); diff --git a/app/src/persistence/mod.rs b/app/src/persistence/mod.rs index 896d4164eec..d96c4cc8f73 100644 --- a/app/src/persistence/mod.rs +++ b/app/src/persistence/mod.rs @@ -47,7 +47,7 @@ use self::model::{AgentConversation, AgentConversationData, Project}; use crate::ai::blocklist::PersistedAIInput; use crate::ai::mcp::TemplatableMCPServerInstallation; use crate::ai::persisted_workspace::EnablementState; -use crate::app_state::AppState; +use crate::app_state::{AppState, RecordedAgentSession}; use crate::auth::auth_manager::PersistedCurrentUserInformation; use crate::cloud_object::model::actions::ObjectAction; use crate::cloud_object::model::generic_string_model::CloudStringObject; @@ -346,6 +346,21 @@ pub struct FinishedCommandMetadata { pub enum ModelEvent { SaveBlock(BlockCompleted), DeleteBlocks(Vec), + /// Records the agent CLI a pane is running, or that it is running none. Deliberately not + /// folded into [`ModelEvent::Snapshot`]: snapshots rebuild the pane tables wholesale, so this + /// state needs a write of its own to survive them. + /// + /// Recording and clearing are one event rather than two so that everything a pane says about + /// its agent stays in one order: a clear that overtook the record it supersedes would leave a + /// finished agent looking resumable. + /// + /// A pane removed for good clears through this same event rather than one of its own, for + /// that same reason: with one ordered, per-pane-coalesced stream the pane's last word wins, + /// whether it came from the agent ending or from the pane going away. + SetAgentSession { + pane_id: Vec, + session: Option, + }, Snapshot(AppState), UpsertWorkflows(Vec), UpsertNotebooks(Vec), diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index ab9616788a9..b095eddf261 100644 --- a/app/src/persistence/sqlite.rs +++ b/app/src/persistence/sqlite.rs @@ -76,9 +76,9 @@ use crate::ai::persisted_workspace::EnablementState; use crate::app_state::{ AIFactPaneSnapshot, AmbientAgentPaneSnapshot, AppState, BranchSnapshot, CodePaneSnapShot, CodePaneTabSnapshot, CodeReviewPaneSnapshot, EnvVarCollectionPaneSnapshot, LeafContents, - LeafSnapshot, LeftPanelSnapshot, NotebookPaneSnapshot, PaneFlex, PaneNodeSnapshot, - RightPanelSnapshot, SettingsPaneSnapshot, SplitDirection, TabGroupSnapshot, TabSnapshot, - TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot, + LeafSnapshot, LeftPanelSnapshot, NotebookPaneSnapshot, PaneFlex, PaneNodeSnapshot, PaneUuid, + RecordedAgentSession, RightPanelSnapshot, SettingsPaneSnapshot, SplitDirection, + TabGroupSnapshot, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot, }; use crate::auth::UserUid; use crate::auth::auth_manager::PersistedCurrentUserInformation; @@ -644,6 +644,13 @@ fn handle_model_event(event: ModelEvent, connection: &mut SqliteConnection) -> a // panes and have their data deleted locally. delete_blocks(connection, pane_id).context("error deleting blocks") } + ModelEvent::SetAgentSession { pane_id, session } => match session { + Some(session) => save_agent_session(connection, pane_id, &session) + .context("error saving agent session"), + None => { + clear_agent_session(connection, pane_id).context("error clearing agent session") + } + }, ModelEvent::Snapshot(app_state) => { save_app_state(connection, &app_state).context("error saving app state") } @@ -888,22 +895,42 @@ fn report_db_error(err_kind: &str, err: anyhow::Error, database_path: &Path) { /// Filter a collection of model events to remove skippable events: /// * [`ModelEvent::Snapshot`] includes the entire app state, so we only need the latest one. +/// * [`ModelEvent::SetAgentSession`] replaces a pane's row wholesale, so only the last one a pane +/// sent says anything. The CLI agent session event that drives it fires once per tool call, so +/// an agent task's worth of captures is hundreds of writes of the same row. +/// +/// Superseded events are dropped where they stand and nothing is reordered: hoisting the +/// surviving snapshot would rebuild the pane tables after a capture that was recorded before it. fn deduplicate_events(events: Vec) -> Vec { - let last_snapshot = events - .iter() - .enumerate() - .rfind(|(_, event)| matches!(event, ModelEvent::Snapshot(_))); - match last_snapshot { - Some((last_snapshot_index, _)) => events - .into_iter() - .enumerate() - .filter_map(|(index, event)| match event { - ModelEvent::Snapshot(_) if index < last_snapshot_index => None, - event => Some(event), - }) - .collect(), - None => events, + let mut superseded: HashSet = HashSet::new(); + let mut last_snapshot: Option = None; + let mut last_agent_session: HashMap<&[u8], usize> = HashMap::new(); + + for (index, event) in events.iter().enumerate() { + match event { + ModelEvent::Snapshot(_) => { + if let Some(previous) = last_snapshot.replace(index) { + superseded.insert(previous); + } + } + ModelEvent::SetAgentSession { pane_id, .. } => { + if let Some(previous) = last_agent_session.insert(pane_id.as_slice(), index) { + superseded.insert(previous); + } + } + _ => {} + } + } + + if superseded.is_empty() { + return events; } + + events + .into_iter() + .enumerate() + .filter_map(|(index, event)| (!superseded.contains(&index)).then_some(event)) + .collect() } // Used in the save_app_state function to help make the code more readable. @@ -1511,6 +1538,100 @@ fn decode_path(bytes: Vec) -> PathBuf { } } +/// Records the agent CLI state observed in a pane, replacing whatever was recorded for it before. +/// +/// A value that fails to serialize is stored as `NULL` instead of aborting the write. This runs on +/// the same writer thread as session snapshots, and losing one field of a resume hint must never +/// escalate into a failed database write. +fn save_agent_session( + conn: &mut SqliteConnection, + pane_id: Vec, + session: &RecordedAgentSession, +) -> Result<()> { + use schema::agent_sessions::dsl::*; + + let new_session = model::NewAgentSession { + pane_leaf_uuid: pane_id, + agent_kind: serde_json::to_string(&session.agent).ok(), + session_id: session.session_id.clone(), + flags: serde_json::to_string(&session.flags).ok(), + directory: encode_path(session.directory.clone()), + observed_at: session.observed_at, + }; + + diesel::insert_into(agent_sessions) + .values(&new_session) + .on_conflict(pane_leaf_uuid) + .do_update() + .set(&new_session) + .execute(conn)?; + + Ok(()) +} + +/// Drops whatever agent CLI state was recorded for a pane, leaving nothing to resume. +fn clear_agent_session(conn: &mut SqliteConnection, pane_id: Vec) -> Result<()> { + use schema::agent_sessions::dsl::*; + + diesel::delete(agent_sessions.filter(pane_leaf_uuid.eq(pane_id))).execute(conn)?; + + Ok(()) +} + +/// Drops recorded agent state belonging to panes the saved session does not restore. +/// +/// This is a safeguard, not the mechanism: the pane-lifecycle hook is what removes a pane's row +/// when the pane is closed for good, because that is the only place that knows the pane will not +/// return. A row can still outlive its pane anyway — a crash between the two writes, or a +/// database written before that hook existed — and a uuid no restored pane claims can never be +/// resumed, so carrying it forever only grows the table. +fn purge_agent_sessions_without_a_restored_pane(conn: &mut SqliteConnection) -> Result<(), Error> { + use schema::agent_sessions::dsl::*; + + let restored_pane_uuids: Vec> = schema::terminal_panes::dsl::terminal_panes + .select(schema::terminal_panes::columns::uuid) + .load(conn)?; + + diesel::delete(agent_sessions.filter(pane_leaf_uuid.ne_all(restored_pane_uuids))) + .execute(conn)?; + + Ok(()) +} + +/// Reads every recorded agent session, keyed by the pane it was recorded for. +/// +/// A row whose stored values no longer parse is dropped instead of failing the read: the pane +/// still restores, just without anything to resume. +fn get_all_recorded_agent_sessions( + conn: &mut SqliteConnection, +) -> Result, Error> { + let rows: Vec = schema::agent_sessions::dsl::agent_sessions + .select(model::AgentSession::as_select()) + .load(conn)?; + + Ok(rows + .into_iter() + .filter_map(|row| { + let agent = row + .agent_kind + .and_then(|kind| serde_json::from_str(&kind).ok())?; + let flags = row + .flags + .and_then(|flags| serde_json::from_str(&flags).ok())?; + Some(( + PaneUuid(row.pane_leaf_uuid), + RecordedAgentSession { + agent, + session_id: row.session_id, + flags, + directory: decode_path(row.directory), + observed_at: row.observed_at, + }, + )) + }) + .collect()) +} + fn save_codebase_index_metadata( conn: &mut SqliteConnection, index_metadata: ai::workspace::WorkspaceMetadata, @@ -2706,6 +2827,22 @@ fn read_sqlite_data( .collect(); let restored_blocks = get_all_restored_blocks(conn)?; + // Housekeeping must never cost the user their session: a database this connection cannot + // write to still restores every pane, it just keeps carrying rows nothing will claim. + if let Err(err) = purge_agent_sessions_without_a_restored_pane(conn) { + report_error!( + anyhow::Error::new(err).context("Error purging orphaned agent session rows") + ); + } + // Reading them must not cost it either: a table only an off-by-default feature reads + // costs the panes whatever agent they were running when it fails, never the windows, tabs + // and panes themselves. + let recorded_agent_sessions = get_all_recorded_agent_sessions(conn).unwrap_or_else(|err| { + report_error!( + anyhow::Error::new(err).context("Error reading recorded agent session rows") + ); + HashMap::new() + }); // Load active MCP servers from database let running_mcp_servers = load_active_mcp_servers(conn)?; @@ -2714,6 +2851,7 @@ fn read_sqlite_data( windows: saved_windows, active_window_index, block_lists: Arc::new(restored_blocks), + agent_sessions: Arc::new(recorded_agent_sessions), running_mcp_servers, }) } else { diff --git a/app/src/persistence/sqlite_tests.rs b/app/src/persistence/sqlite_tests.rs index 7fcb746f38f..07e33cb4105 100644 --- a/app/src/persistence/sqlite_tests.rs +++ b/app/src/persistence/sqlite_tests.rs @@ -3,9 +3,10 @@ use std::path::PathBuf; use std::sync::Arc; use ai::workspace::WorkspaceMetadata; -use chrono::{Local, Utc}; +use chrono::{Local, NaiveDate, Utc}; use cloud_object_persistence::to_cloud_object_permissions; use diesel::connection::SimpleConnection; +use diesel_migrations::MigrationHarness; use pathfinder_geometry::rect::RectF; use pathfinder_geometry::vector::Vector2F; use warp_core::features::FeatureFlag; @@ -14,11 +15,13 @@ use warp_graphql::scalars::time::ServerTimestamp; use super::{ app_database_file_path, database_file_path_for_current_scope, database_file_path_for_scope, decode_path, deduplicate_events, encode_path, get_all_codebase_index_metadata, - read_sqlite_data, save_app_state, save_codebase_index_metadata, setup_database, start_writer, + get_all_recorded_agent_sessions, get_all_restored_blocks, read_sqlite_data, save_agent_session, + save_app_state, save_codebase_index_metadata, setup_database, start_writer, }; use crate::app_state::{ AppState, CodePaneSnapShot, CodePaneTabSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot, - TabGroupSnapshot, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot, + PaneUuid, RecordedAgentSession, TabGroupSnapshot, TabSnapshot, TerminalPaneSnapshot, + WindowSnapshot, }; use crate::auth::UserUid; use crate::cloud_object::{CloudObjectPermissions, Owner}; @@ -30,9 +33,10 @@ use crate::persistence::{ }; use crate::server::ids::{ClientId, ServerId}; use crate::tab::SelectedTabColor; -use crate::terminal::ShellLaunchData; +use crate::terminal::cli_agent_resume::RecordedFlag; use crate::terminal::model::block::SerializedBlock; use crate::terminal::model::session::SessionId; +use crate::terminal::{CLIAgent, ShellLaunchData}; use crate::themes::theme::AnsiColorIdentifier; use crate::workspace::tab_group::TabGroupId; use crate::workspaces::user_profiles::UserProfileWithUID; @@ -149,6 +153,7 @@ fn sqlite_read_restores_app_state_and_codebase_metadata() { windows: vec![test_terminal_window_snapshot(false)], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; save_app_state(&mut conn, &app_state).expect("app state should save"); @@ -307,18 +312,21 @@ fn test_deduplicate_snapshots() { let snapshot_1 = AppState { active_window_index: Some(1), block_lists: Default::default(), + agent_sessions: Default::default(), windows: Default::default(), running_mcp_servers: Default::default(), }; let snapshot_2 = AppState { active_window_index: Some(2), block_lists: Default::default(), + agent_sessions: Default::default(), windows: Default::default(), running_mcp_servers: Default::default(), }; let snapshot_3 = AppState { active_window_index: Some(3), block_lists: Default::default(), + agent_sessions: Default::default(), windows: Default::default(), running_mcp_servers: Default::default(), }; @@ -359,6 +367,102 @@ fn test_deduplicate_snapshots() { )); } +// KTD14: the CLI agent session event fires once per tool call, so an agent task's worth of +// captures is hundreds of writes of the same row. Only the last one for a pane says anything. +#[test] +fn agent_session_writes_for_one_pane_collapse_to_the_last_one() { + let first = test_recorded_agent_session(); + let mut second = test_recorded_agent_session(); + second.session_id = "the-newer-identifier".to_owned(); + + let filtered = deduplicate_events(vec![ + agent_session_event(AGENT_PANE_UUID.to_vec(), Some(first)), + agent_session_event(AGENT_PANE_UUID.to_vec(), Some(second.clone())), + ]); + + assert_eq!( + recorded_sessions_in(&filtered), + vec![(AGENT_PANE_UUID.to_vec(), Some(second))], + "two writes for one pane must collapse to the later one, not to whichever the writer \ + happened to reach first" + ); +} + +// Coalescing is per pane: a burst from one pane must not swallow another pane's state. +#[test] +fn agent_session_writes_for_different_panes_are_all_kept() { + let session = test_recorded_agent_session(); + + let filtered = deduplicate_events(vec![ + agent_session_event(vec![1], Some(session.clone())), + agent_session_event(vec![2], Some(session.clone())), + agent_session_event(vec![1], Some(session.clone())), + ]); + + assert_eq!( + recorded_sessions_in(&filtered), + vec![ + (vec![2], Some(session.clone())), + (vec![1], Some(session.clone())), + ], + "each pane keeps its own latest write" + ); +} + +// A clear is a write like any other, so it has to supersede an earlier record for the same pane. +// A record that outlived the clear would offer to resume an agent that has already exited (R3). +#[test] +fn agent_session_clear_supersedes_an_earlier_record_for_the_same_pane() { + let filtered = deduplicate_events(vec![ + agent_session_event( + AGENT_PANE_UUID.to_vec(), + Some(test_recorded_agent_session()), + ), + agent_session_event(AGENT_PANE_UUID.to_vec(), None), + ]); + + assert_eq!( + recorded_sessions_in(&filtered), + vec![(AGENT_PANE_UUID.to_vec(), None)], + "the clear is the pane's latest word about its agent" + ); +} + +// Coalescing may only drop superseded writes. Moving the surviving snapshot earlier would run a +// pane-table rebuild after a capture that was recorded before it. +#[test] +fn coalescing_agent_session_writes_leaves_snapshot_ordering_alone() { + let snapshot = AppState { + windows: vec![test_terminal_window_snapshot(false)], + active_window_index: Some(0), + block_lists: Default::default(), + agent_sessions: Default::default(), + running_mcp_servers: Default::default(), + }; + + let filtered = deduplicate_events(vec![ + agent_session_event( + AGENT_PANE_UUID.to_vec(), + Some(test_recorded_agent_session()), + ), + ModelEvent::Snapshot(snapshot.clone()), + agent_session_event(AGENT_PANE_UUID.to_vec(), None), + ]); + + let shapes = filtered + .iter() + .map(|event| match event { + ModelEvent::Snapshot(_) => "snapshot", + _ => "agent session", + }) + .collect::>(); + assert_eq!( + shapes, + vec!["snapshot", "agent session"], + "the surviving capture write must stay after the snapshot it followed" + ); +} + #[test] fn test_deduplicate_no_snapshots() { let original_events = vec![ModelEvent::SaveBlock(BlockCompleted { @@ -432,6 +536,7 @@ fn test_sqlite_round_trips_vertical_tabs_panel_open() { ], active_window_index: Some(1), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; @@ -466,6 +571,7 @@ fn test_sqlite_round_trips_window_team_uid() { windows: vec![assigned_window, test_terminal_window_snapshot(true)], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; @@ -534,6 +640,7 @@ fn test_sqlite_round_trips_custom_vertical_tabs_title() { }], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; @@ -613,6 +720,7 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() { }], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; @@ -738,6 +846,7 @@ fn test_sqlite_round_trips_tab_groups() { }], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; @@ -899,6 +1008,7 @@ fn test_sqlite_round_trips_pinned_state() { }], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; @@ -1032,6 +1142,7 @@ fn test_sqlite_drops_too_small_bounds_on_save() { windows: vec![snapshot], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; @@ -1071,6 +1182,7 @@ fn test_sqlite_drops_too_small_bounds_on_read() { windows: vec![test_terminal_window_snapshot(false)], active_window_index: Some(0), block_lists: Default::default(), + agent_sessions: Default::default(), running_mcp_servers: Default::default(), }; save_app_state(&mut conn, &app_state).expect("app state should save"); @@ -1093,3 +1205,405 @@ fn test_sqlite_drops_too_small_bounds_on_read() { "tiny persisted bounds must be discarded on read so users recover from a corrupt DB" ); } + +const AGENT_PANE_UUID: [u8; 1] = [1]; + +/// A capture write for `pane_id`, as a pane sends it. `None` is the pane reporting that it has +/// no agent session to resume. +fn agent_session_event(pane_id: Vec, session: Option) -> ModelEvent { + ModelEvent::SetAgentSession { pane_id, session } +} + +/// The capture writes in `events`, in the order the writer would apply them. +fn recorded_sessions_in(events: &[ModelEvent]) -> Vec<(Vec, Option)> { + events + .iter() + .filter_map(|event| match event { + ModelEvent::SetAgentSession { pane_id, session } => { + Some((pane_id.clone(), session.clone())) + } + _ => None, + }) + .collect() +} + +fn test_recorded_agent_session() -> RecordedAgentSession { + RecordedAgentSession { + agent: CLIAgent::Claude, + session_id: "b7c2f1a0-5f3e-4c21-9b8d-0f2a1c3d4e5f".to_owned(), + flags: vec![RecordedFlag { + name: "--model".to_owned(), + value: Some("opus".to_owned()), + }], + directory: PathBuf::from("/tmp/agent-project"), + observed_at: NaiveDate::from_ymd_opt(2026, 8, 11) + .expect("date should be valid") + .and_hms_opt(9, 30, 0) + .expect("time should be valid"), + } +} + +/// A database holding one restorable window whose single terminal pane is [`AGENT_PANE_UUID`]. +fn database_with_saved_session(database_path: &std::path::Path) -> diesel::SqliteConnection { + let mut conn = setup_database(database_path).expect("database should initialize"); + let app_state = AppState { + windows: vec![test_terminal_window_snapshot(false)], + active_window_index: Some(0), + block_lists: Default::default(), + agent_sessions: Default::default(), + running_mcp_servers: Default::default(), + }; + save_app_state(&mut conn, &app_state).expect("app state should save"); + conn +} + +#[test] +fn agent_session_round_trips_through_save_and_load() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + let recorded = test_recorded_agent_session(); + + save_agent_session(&mut conn, AGENT_PANE_UUID.to_vec(), &recorded) + .expect("agent session should save"); + + let restored = read_sqlite_data(&mut conn, None, PersistedDataScope::Full) + .expect("app state should load") + .app_state + .expect("app state should be present for the full scope"); + + assert_eq!( + restored + .agent_sessions + .get(&PaneUuid(AGENT_PANE_UUID.to_vec())), + Some(&recorded) + ); +} + +// AE3: a restart after a kill has only what already reached the disk, and the capture write +// reaches it on its own — no snapshot is sent before or after it. Terminating the writer here +// only joins the thread; nothing else is written. +#[test] +fn agent_session_observed_before_an_abrupt_exit_is_already_on_disk() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let database_path = tempdir.path().join("warp.sqlite"); + let conn = database_with_saved_session(&database_path); + let recorded = test_recorded_agent_session(); + + let writer = start_writer(conn, database_path.clone()).expect("writer should start"); + writer + .sender + .send(agent_session_event( + AGENT_PANE_UUID.to_vec(), + Some(recorded.clone()), + )) + .expect("capture event should send"); + writer + .sender + .send(ModelEvent::Terminate) + .expect("terminate event should send"); + writer.handle.join().expect("writer should terminate"); + + let mut conn = setup_database(&database_path).expect("database should reopen"); + assert_eq!( + get_all_recorded_agent_sessions(&mut conn) + .expect("agent sessions should load") + .get(&PaneUuid(AGENT_PANE_UUID.to_vec())), + Some(&recorded), + "the last observed state must be on disk without a snapshot to carry it" + ); +} + +// R3: a pane that reports no agent session has its row removed, so the next launch reads absence +// rather than the session that pane used to be running. +#[test] +fn agent_session_clear_removes_the_panes_row() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + + super::handle_model_event( + agent_session_event( + AGENT_PANE_UUID.to_vec(), + Some(test_recorded_agent_session()), + ), + &mut conn, + ) + .expect("capture event should apply"); + super::handle_model_event( + agent_session_event(AGENT_PANE_UUID.to_vec(), None), + &mut conn, + ) + .expect("clear event should apply"); + + assert!( + get_all_recorded_agent_sessions(&mut conn) + .expect("agent sessions should load") + .is_empty(), + "a cleared pane must be left with nothing to resume" + ); +} + +// A repeated capture for one pane replaces its row rather than adding another: the table is +// keyed by pane, and a second row for the same pane would make the resume offer ambiguous. +#[test] +fn repeated_agent_session_captures_update_one_row_per_pane() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + let mut newer = test_recorded_agent_session(); + newer.session_id = "the-newer-identifier".to_owned(); + + super::handle_model_event( + agent_session_event( + AGENT_PANE_UUID.to_vec(), + Some(test_recorded_agent_session()), + ), + &mut conn, + ) + .expect("first capture event should apply"); + super::handle_model_event( + agent_session_event(AGENT_PANE_UUID.to_vec(), Some(newer.clone())), + &mut conn, + ) + .expect("second capture event should apply"); + + let loaded = get_all_recorded_agent_sessions(&mut conn).expect("agent sessions should load"); + assert_eq!(loaded.len(), 1, "a pane owns exactly one recorded session"); + assert_eq!( + loaded.get(&PaneUuid(AGENT_PANE_UUID.to_vec())), + Some(&newer) + ); +} + +#[test] +fn agent_session_is_absent_for_pane_without_a_recorded_row() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + + let restored = read_sqlite_data(&mut conn, None, PersistedDataScope::Full) + .expect("app state should load") + .app_state + .expect("app state should be present for the full scope"); + + assert!(restored.agent_sessions.is_empty()); +} + +// KTD13 safeguard: the pane-lifecycle hook is what removes a gone pane's row, but a row can still +// outlive its pane — a crash between the two, or a database written before that hook existed. No +// launch can ever claim a uuid the snapshot does not restore, so the load drops it rather than +// carrying it forever. +#[test] +fn load_removes_recorded_state_for_a_pane_the_snapshot_does_not_restore() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + let recorded = test_recorded_agent_session(); + + save_agent_session(&mut conn, AGENT_PANE_UUID.to_vec(), &recorded) + .expect("agent session should save"); + save_agent_session(&mut conn, vec![9], &recorded) + .expect("the orphaned agent session should save"); + + let restored = read_sqlite_data(&mut conn, None, PersistedDataScope::Full) + .expect("app state should load") + .app_state + .expect("app state should be present for the full scope"); + + assert_eq!(restored.agent_sessions.get(&PaneUuid(vec![9])), None); + assert_eq!( + restored + .agent_sessions + .get(&PaneUuid(AGENT_PANE_UUID.to_vec())), + Some(&recorded), + "the pane the snapshot restores keeps what it recorded" + ); + assert_eq!( + get_all_recorded_agent_sessions(&mut conn) + .expect("agent sessions should load") + .into_keys() + .collect::>(), + vec![PaneUuid(AGENT_PANE_UUID.to_vec())], + "the orphaned row must be gone from the table, not merely filtered out of the load" + ); +} + +// The sweep is only allowed to remove what no pane claims. A row whose pane the snapshot restores +// is the entire point of the table, and a sweep that took it would break resume on every launch. +#[test] +fn load_keeps_recorded_state_for_every_pane_the_snapshot_restores() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + let recorded = test_recorded_agent_session(); + + save_agent_session(&mut conn, AGENT_PANE_UUID.to_vec(), &recorded) + .expect("agent session should save"); + + read_sqlite_data(&mut conn, None, PersistedDataScope::Full).expect("app state should load"); + + assert_eq!( + get_all_recorded_agent_sessions(&mut conn) + .expect("agent sessions should load") + .get(&PaneUuid(AGENT_PANE_UUID.to_vec())), + Some(&recorded), + "a claimed row must survive the load untouched" + ); +} + +// A saved session with no terminal pane at all leaves the sweep with an empty set to compare +// against. Every row is orphaned in that case, and the comparison itself has to stay valid SQL — +// a load that errors here would cost the user every window, not just a stale row. +#[test] +fn load_removes_recorded_state_when_the_snapshot_restores_no_terminal_pane() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = + setup_database(&tempdir.path().join("warp.sqlite")).expect("database should initialize"); + let app_state = AppState { + windows: vec![], + active_window_index: None, + block_lists: Default::default(), + agent_sessions: Default::default(), + running_mcp_servers: Default::default(), + }; + save_app_state(&mut conn, &app_state).expect("app state should save"); + save_agent_session( + &mut conn, + AGENT_PANE_UUID.to_vec(), + &test_recorded_agent_session(), + ) + .expect("agent session should save"); + + read_sqlite_data(&mut conn, None, PersistedDataScope::Full).expect("app state should load"); + + assert!( + get_all_recorded_agent_sessions(&mut conn) + .expect("agent sessions should load") + .is_empty(), + "no pane restored, so no row is claimed" + ); +} + +#[test] +fn agent_session_with_malformed_stored_value_loads_as_absent() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + let recorded = test_recorded_agent_session(); + + save_agent_session(&mut conn, AGENT_PANE_UUID.to_vec(), &recorded) + .expect("agent session should save"); + save_agent_session(&mut conn, vec![9], &recorded).expect("second agent session should save"); + conn.batch_execute( + "UPDATE agent_sessions SET agent_kind = 'not json' WHERE pane_leaf_uuid = X'01'", + ) + .expect("corrupting update should succeed"); + + let loaded = get_all_recorded_agent_sessions(&mut conn) + .expect("a malformed row must not fail the whole load"); + + assert_eq!(loaded.get(&PaneUuid(AGENT_PANE_UUID.to_vec())), None); + assert_eq!(loaded.get(&PaneUuid(vec![9])), Some(&recorded)); +} + +// The writer degrades a value it cannot serialize to NULL. That degraded row must still be +// accepted by the schema, because a rejected insert would surface as a database write error. +#[test] +fn agent_session_write_that_lost_a_value_still_lands_and_leaves_snapshots_intact() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + + conn.batch_execute( + "INSERT INTO agent_sessions \ + (pane_leaf_uuid, agent_kind, session_id, flags, directory, observed_at) \ + VALUES (X'01', NULL, 'session-1', NULL, X'2F746D70', '2026-08-11 09:30:00')", + ) + .expect("a row whose serialized values were dropped must still insert"); + + let loaded = + get_all_recorded_agent_sessions(&mut conn).expect("degraded row must not fail the load"); + assert!(loaded.is_empty()); + + let app_state = AppState { + windows: vec![test_terminal_window_snapshot(false)], + active_window_index: Some(0), + block_lists: Default::default(), + agent_sessions: Default::default(), + running_mcp_servers: Default::default(), + }; + save_app_state(&mut conn, &app_state).expect("snapshot transaction must still commit"); +} + +// The reason agent state lives in its own table: `save_app_state` deletes and rebuilds every pane +// table, so a value stored on `terminal_panes` would be reverted by the next snapshot. +#[test] +fn full_session_save_leaves_recorded_agent_sessions_untouched() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + let recorded = test_recorded_agent_session(); + + save_agent_session(&mut conn, AGENT_PANE_UUID.to_vec(), &recorded) + .expect("agent session should save"); + + let app_state = AppState { + windows: vec![test_terminal_window_snapshot(false)], + active_window_index: Some(0), + block_lists: Default::default(), + agent_sessions: Default::default(), + running_mcp_servers: Default::default(), + }; + save_app_state(&mut conn, &app_state).expect("app state should save"); + + let loaded = get_all_recorded_agent_sessions(&mut conn).expect("agent sessions should load"); + assert_eq!( + loaded.get(&PaneUuid(AGENT_PANE_UUID.to_vec())), + Some(&recorded) + ); +} + +// AE15: restore itself triggers a snapshot save per pane once its shell bootstraps, so the +// recorded state has to survive saves that happen *during* the restore that wants to read it. +#[test] +fn recorded_agent_session_survives_snapshot_saves_triggered_during_restore() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + let recorded = test_recorded_agent_session(); + + save_agent_session(&mut conn, AGENT_PANE_UUID.to_vec(), &recorded) + .expect("agent session should save"); + + let app_state = AppState { + windows: vec![test_terminal_window_snapshot(false)], + active_window_index: Some(0), + block_lists: Default::default(), + agent_sessions: Default::default(), + running_mcp_servers: Default::default(), + }; + for _ in 0..3 { + save_app_state(&mut conn, &app_state).expect("bootstrap snapshot should save"); + } + + let restored = read_sqlite_data(&mut conn, None, PersistedDataScope::Full) + .expect("app state should load") + .app_state + .expect("app state should be present for the full scope"); + + assert_eq!( + restored + .agent_sessions + .get(&PaneUuid(AGENT_PANE_UUID.to_vec())), + Some(&recorded) + ); +} + +#[test] +fn agent_sessions_migration_down_drops_only_its_own_table() { + let tempdir = tempfile::tempdir().expect("tempdir should be created"); + let mut conn = database_with_saved_session(&tempdir.path().join("warp.sqlite")); + + conn.revert_last_migration(persistence::MIGRATIONS) + .expect("the agent sessions migration should revert"); + + assert!( + get_all_recorded_agent_sessions(&mut conn).is_err(), + "down.sql should have dropped agent_sessions" + ); + assert!( + get_all_restored_blocks(&mut conn).is_ok(), + "down.sql must leave the other tables alone" + ); +} diff --git a/app/src/root_view.rs b/app/src/root_view.rs index 96cfeeca15b..ddefa1b0274 100644 --- a/app/src/root_view.rs +++ b/app/src/root_view.rs @@ -46,7 +46,7 @@ use crate::ai::onboarding::{ onboarding_pricing_promotion_message, }; use crate::ai::request_usage_model::AIRequestUsageModelEvent; -use crate::app_state::{AppState, PaneUuid, WindowSnapshot}; +use crate::app_state::{AgentSessionRestore, AppState, PaneUuid, WindowSnapshot}; use crate::appearance::Appearance; use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; use crate::auth::auth_override_warning_modal::{ @@ -73,7 +73,7 @@ use crate::interval_timer::IntervalTimer; use crate::launch_configs::launch_config; use crate::linear::LinearIssueWork; use crate::notebooks::manager::NotebookSource; -use crate::pane_group::{NewTerminalOptions, PanesLayout}; +use crate::pane_group::{NewTerminalOptions, PanesLayout, resolve_agent_session_claims}; use crate::persistence::ModelEvent; use crate::pricing::{PricingInfoModel, PricingInfoModelEvent}; use crate::server::cloud_objects::update_manager::UpdateManager; @@ -797,6 +797,21 @@ fn open_from_restored(arg: &OpenFromRestoredArg, ctx: &mut AppContext) { if *GeneralSettings::as_ref(ctx).restore_session { let mut active_index = None; let mut normal_window_count = 0; + // This is the one restore pass that may resume agents; tabs restored from a snapshot + // later in the session go through the same code with this left unset. + // + // Claims are resolved here, ahead of the window loop, because this is the only point + // that sees every window: each window below is its own `add_window` call, and the + // window the user lands in is created last. + let agent_restore = AgentSessionRestore { + sessions: app_state.agent_sessions.clone(), + claimed_panes: Arc::new(resolve_agent_session_claims( + &app_state.windows, + app_state.active_window_index, + &app_state.agent_sessions, + )), + is_startup_restore: true, + }; for (idx, window) in app_state.windows.iter().enumerate() { // If this window is a quake window, hide it by default. if window.quake_mode { @@ -834,6 +849,7 @@ fn open_from_restored(arg: &OpenFromRestoredArg, ctx: &mut AppContext) { NewWorkspaceSource::Restored { window_snapshot: window.clone(), block_lists: app_state.block_lists.clone(), + agent_restore: agent_restore.clone(), }, ctx, ); @@ -874,6 +890,7 @@ fn open_from_restored(arg: &OpenFromRestoredArg, ctx: &mut AppContext) { NewWorkspaceSource::Restored { window_snapshot: window.clone(), block_lists: app_state.block_lists.clone(), + agent_restore: agent_restore.clone(), }, ctx, ); @@ -926,6 +943,7 @@ fn open_from_restored(arg: &OpenFromRestoredArg, ctx: &mut AppContext) { NewWorkspaceSource::Restored { window_snapshot: window.clone(), block_lists: app_state.block_lists.clone(), + agent_restore: agent_restore.clone(), }, ctx, ); @@ -1618,6 +1636,7 @@ pub enum NewWorkspaceSource { Restored { window_snapshot: WindowSnapshot, block_lists: Arc>>, + agent_restore: AgentSessionRestore, }, Session { options: Box, @@ -3130,6 +3149,7 @@ impl RootView { .with_initial_directory_opt(path_if_directory(path).map(Into::into)), )), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -3305,6 +3325,7 @@ impl RootView { workspace.add_tab_with_pane_layout( PanesLayout::SingleTerminal(Box::default()), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -3350,6 +3371,7 @@ impl RootView { workspace.add_tab_with_pane_layout( PanesLayout::SingleTerminal(Box::default()), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); diff --git a/app/src/terminal/cli_agent.rs b/app/src/terminal/cli_agent.rs index ee2e6dd4bb2..6ff2988f997 100644 --- a/app/src/terminal/cli_agent.rs +++ b/app/src/terminal/cli_agent.rs @@ -381,7 +381,7 @@ impl CLIAgent { } /// Returns whether the command's executable name identifies this CLI agent. - pub(super) fn matches_command(&self, command: &str, escape_char: Option) -> bool { + pub(crate) fn matches_command(&self, command: &str, escape_char: Option) -> bool { let Some(first_word) = Self::extract_first_command(command.trim_start(), escape_char) else { return false; @@ -390,6 +390,35 @@ impl CLIAgent { self.command_prefixes().contains(&basename) } + /// Resolves the full command through aliases. If the first word matches an + /// alias, it is replaced with the alias value to produce the resolved command, + /// which is the command line the shell actually ran. + /// + /// Only resolvable while the originating shell session is alive, which is why callers that + /// care about the resolved form — detection, and recording an agent's own flags — have to do + /// it as they observe the command rather than when they later use it. + pub(crate) fn resolve_command_aliases<'a>( + command: &'a str, + escape_char: Option, + aliases: Option<&HashMap>, + ) -> Cow<'a, str> { + let trimmed = command.trim_start(); + let Some(first_word) = Self::extract_first_command(trimmed, escape_char) else { + return Cow::Borrowed(trimmed); + }; + + aliases + .and_then(|a| a.get(first_word.as_str())) + .map(|alias_value| { + let rest = trimmed + .find(first_word.as_str()) + .map(|pos| &trimmed[pos + first_word.len()..]) + .unwrap_or(""); + Cow::Owned(format!("{}{}", alias_value.trim(), rest)) + }) + .unwrap_or(Cow::Borrowed(trimmed)) + } + /// Detects the CLI agent from a command string. /// /// When `escape_char` is provided, full shell parsing is used to skip leading @@ -408,20 +437,9 @@ impl CLIAgent { ctx: &AppContext, ) -> Option { let trimmed = command.trim_start(); - let first_word = Self::extract_first_command(trimmed, escape_char)?; - - // Resolve the full command through aliases. If the first word matches an - // alias, replace it with the alias value to produce the resolved command. - let resolved_command: Cow<'_, str> = aliases - .and_then(|a| a.get(first_word.as_str())) - .map(|alias_value| { - let rest = trimmed - .find(first_word.as_str()) - .map(|pos| &trimmed[pos + first_word.len()..]) - .unwrap_or(""); - Cow::Owned(format!("{}{}", alias_value.trim(), rest)) - }) - .unwrap_or(Cow::Borrowed(trimmed)); + // A command without a first word names no program, so it identifies no agent. + Self::extract_first_command(trimmed, escape_char)?; + let resolved_command = Self::resolve_command_aliases(trimmed, escape_char, aliases); // Check if resolved command matches any known CLI agent. // Also matches `aifx agent run claude` as Claude for Uber employees. diff --git a/app/src/terminal/cli_agent_resume.rs b/app/src/terminal/cli_agent_resume.rs new file mode 100644 index 00000000000..ba5cf62a2f2 --- /dev/null +++ b/app/src/terminal/cli_agent_resume.rs @@ -0,0 +1,568 @@ +//! Per-agent resume support, declared as embedded configuration. +//! +//! Support for an agent is a declaration in `resources/cli_agent_resume/agents.toml` +//! rather than a restore path in code: the file supplies the invocation shape that +//! reattaches to a prior session, the allowlist of flags carried over from the user's +//! own invocation, and the shape each of those values must match. It never names an +//! executable — the binary comes from [`CLIAgent::command_prefix`]. +//! +//! Recorded values are untrusted. They come from a local database file that any +//! process running as the user can write, and the string built here is parsed by an +//! interactive shell, so a stored value is a code-execution primitive until it has +//! been checked. Validation therefore happens when the invocation is built, not when +//! the flags were captured, and a value that fails is dropped rather than repaired. + +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::Duration; + +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use warp_errors::report_error; + +use crate::terminal::CLIAgent; +use crate::terminal::shell::ShellType; + +/// Trailing comment appended to every built resume invocation so the shell keeps it +/// out of history: a resume is Warp's line, not something the user typed. +/// +/// Matched literally by every bootstrap script in `app/assets/bundled/bootstrap/` that +/// can be told which command patterns to omit from history, so changing this string +/// means changing all of them together. `#` starts a comment in each of those shells, +/// so the marker stays inert. Fish takes no such patterns and is suppressed by +/// [`history_suppressed_resume_command`] instead. +pub const RESUME_HISTORY_MARKER: &str = "warp_resume_agent_session"; + +const EMBEDDED_DECLARATIONS: &str = include_str!("../../resources/cli_agent_resume/agents.toml"); + +/// How recently the recorded state must have been observed for its permission-posture flags to +/// ride along into the resume. Past this, the pane still resumes — just at the posture the agent +/// defaults to. +/// +/// Provisional: 12 hours is a placeholder pending field data on how long a pane realistically sits +/// between the last observation and the restart. The shipped value is a rollout decision, not an +/// implementation one. +pub const PERMISSION_POSTURE_FRESHNESS: Duration = Duration::from_secs(12 * 60 * 60); + +/// Whether the permission posture recorded alongside a session is still the user's live choice. +/// +/// The user's own invocation is the authority on the posture it ran at, but that authority +/// expires: a pane whose recording is a week old is not a window the user is still standing in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionPosture { + /// Observed inside [`PERMISSION_POSTURE_FRESHNESS`]: carry the flags the user chose. + Carry, + /// Observed outside it, or at an age no clock can vouch for. Resume without them. + Drop, +} + +impl PermissionPosture { + /// The posture for state last observed at `observed_at`, judged against `now`. + pub fn for_observation(observed_at: NaiveDateTime, now: NaiveDateTime) -> Self { + match (now - observed_at).to_std() { + Ok(age) if age <= PERMISSION_POSTURE_FRESHNESS => PermissionPosture::Carry, + // A negative age means the clock moved backwards between the recording and this + // restart, so nothing here can vouch for how old the recording is. An age that + // cannot be verified is not a fresh one. + _ => PermissionPosture::Drop, + } + } +} + +/// Characters a [`ValueShape::BareToken`] may contain on top of ASCII alphanumerics. +const BARE_TOKEN_PUNCTUATION: &[char] = &['.', '_', '-', '+', ':', '@']; + +/// The shape a value must match to survive into a built invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ValueShape { + /// The flag stands alone; anything recorded alongside it is not this flag. + Boolean, + /// ASCII alphanumerics plus [`BARE_TOKEN_PUNCTUATION`], not starting with `-`. + BareToken, + /// [`ValueShape::BareToken`] plus `/`. + PathLike, +} + +impl ValueShape { + fn accepts_char(self, c: char) -> bool { + match self { + ValueShape::Boolean => false, + ValueShape::BareToken => { + c.is_ascii_alphanumeric() || BARE_TOKEN_PUNCTUATION.contains(&c) + } + ValueShape::PathLike => c == '/' || ValueShape::BareToken.accepts_char(c), + } + } + + /// Whether `value` may be passed to a shell once quoted. Everything outside the + /// declared character set is rejected, which covers whitespace, shell + /// metacharacters, globs, newlines, quotes and non-ASCII lookalikes in one rule. + fn accepts(self, value: &str, max_length: usize) -> bool { + !value.is_empty() + && value.len() <= max_length + // A value that opens with `-` would be read as a flag rather than the + // value of the flag it follows. + && !value.starts_with('-') + && value.chars().all(|c| self.accepts_char(c)) + } +} + +/// A resume-relevant flag recorded from the user's own invocation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordedFlag { + pub name: String, + pub value: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum DeclarationError { + #[error("resume declarations are not valid TOML: {0}")] + Toml(#[from] toml::de::Error), + #[error("`{0}` is not a CLI agent Warp knows")] + UnknownAgent(String), + #[error("`{0}` has no command prefix to resume with")] + NoCommand(String), + #[error("`{agent}` declares an unusable resume invocation: {reason}")] + Invocation { agent: String, reason: &'static str }, + #[error("`{agent}` declares an unusable value for `{key}`: {reason}")] + Value { + agent: String, + key: String, + reason: &'static str, + }, +} + +/// The two invocation shapes agents use to reattach to a session, as written in the +/// declaration file. Kept as one struct with optional members so that +/// `deny_unknown_fields` applies and a mismatched pair is rejected at load with a +/// reason rather than deserialized into a half-shape. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct RawResume { + form: ResumeForm, + flag: Option, + subcommand: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ResumeForm { + Flag, + Subcommand, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct RawValue { + shape: ValueShape, + max_length: Option, + #[serde(default)] + aliases: Vec, + /// Whether this flag chooses the agent's permission posture rather than describing the + /// session. Declared per flag because only the allowlist knows which spelling elevates. + #[serde(default)] + permission_posture: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct RawAgent { + resume: RawResume, + identifier: RawValue, + #[serde(default)] + flags: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct RawFile { + agents: HashMap, +} + +/// A validated resume invocation shape. +#[derive(Debug)] +enum ResumeInvocation { + /// ` --resume `. + Flag(String), + /// ` resume `. + Subcommand(String), +} + +#[derive(Debug)] +struct ValueDeclaration { + shape: ValueShape, + max_length: usize, + permission_posture: bool, +} + +impl ValueDeclaration { + fn accepts(&self, value: &str) -> bool { + self.shape.accepts(value, self.max_length) + } +} + +#[derive(Debug)] +struct AgentDeclaration { + binary: &'static str, + resume: ResumeInvocation, + identifier: ValueDeclaration, + flags: HashMap, + /// Alternate spellings mapped to the allowlisted name they stand for. + aliases: HashMap, +} + +impl AgentDeclaration { + /// The allowlisted name `recorded` stands for, if any. + fn canonical_name<'a>(&'a self, recorded: &'a str) -> Option<&'a str> { + if self.flags.contains_key(recorded) { + return Some(recorded); + } + self.aliases.get(recorded).map(String::as_str) + } +} + +#[derive(Debug, Default)] +pub struct ResumeDeclarations { + agents: HashMap, +} + +impl ResumeDeclarations { + /// The declarations embedded at build time. A file that fails to load leaves every + /// agent unsupported, which costs a resume rather than risking a wrong one. + pub fn embedded() -> &'static Self { + static DECLARATIONS: LazyLock = LazyLock::new(|| { + ResumeDeclarations::parse(EMBEDDED_DECLARATIONS).unwrap_or_else(|_| { + report_error!("embedded CLI agent resume declarations failed to load"); + ResumeDeclarations::default() + }) + }); + &DECLARATIONS + } + + fn parse(contents: &str) -> Result { + let raw: RawFile = toml::from_str(contents)?; + let mut agents = HashMap::with_capacity(raw.agents.len()); + + for (name, declaration) in raw.agents { + let agent = CLIAgent::from_serialized_name(&name); + if agent.to_serialized_name() != name { + return Err(DeclarationError::UnknownAgent(name)); + } + let binary = agent.command_prefix(); + if binary.is_empty() { + return Err(DeclarationError::NoCommand(name)); + } + agents.insert(agent, AgentDeclaration::build(&name, binary, declaration)?); + } + + Ok(Self { agents }) + } + + pub fn supports(&self, agent: CLIAgent) -> bool { + self.agents.contains_key(&agent) + } + + /// The allowlisted flags present in an agent's own command line, with alternate + /// spellings resolved to the name the allowlist uses. + /// + /// Values are recorded as they were seen. They are untrusted either way — the + /// store they land in is writable by other processes — so checking them here would + /// buy nothing that [`Self::build_resume_command`] does not have to redo. + pub fn extract_resume_flags( + &self, + agent: CLIAgent, + args: &[impl AsRef], + ) -> Vec { + let Some(declaration) = self.agents.get(&agent) else { + return Vec::new(); + }; + + let mut recorded = Vec::new(); + let mut index = 0; + while index < args.len() { + let arg = args[index].as_ref(); + index += 1; + // The flags end at the first word that is neither a flag nor a value one consumed — + // `--` says so explicitly, a positional says so by being one. No declared agent takes + // an allowlisted flag after its prompt positional, so past that point a flag-shaped + // word is text the user wrote rather than a choice they made, and reading it as a + // choice is how a prompt quoting `--permission-mode` becomes a recorded posture. This + // is the second of two barriers: the caller tokenizes the line the way the shell does, + // so a quoted prompt arrives here as one word to stop on. + if arg == "--" || !arg.starts_with('-') { + break; + } + + let (spelling, inline_value) = match arg.split_once('=') { + Some((spelling, value)) => (spelling, Some(value)), + None => (arg, None), + }; + let Some(name) = declaration.canonical_name(spelling) else { + continue; + }; + let takes_value = declaration + .flags + .get(name) + .is_some_and(|flag| flag.shape != ValueShape::Boolean); + + let value = match (takes_value, inline_value) { + (false, None) => None, + // A boolean flag given a value is not the flag the allowlist declared. + (false, Some(_)) => continue, + (true, Some(value)) => Some(value.to_owned()), + (true, None) => { + // A separated value never opens with `-`; taking one that does + // would swallow the next flag. + let next = args + .get(index) + .map(AsRef::as_ref) + .filter(|next| !next.starts_with('-')); + let Some(next) = next else { + continue; + }; + index += 1; + Some(next.to_owned()) + } + }; + recorded.push(RecordedFlag { + name: name.to_owned(), + value, + }); + } + + recorded + } + + /// The allowlisted flags `agent` declares as choosing a permission posture. + /// + /// Exposed so the declared set can be pinned against an acknowledged one: marking a flag as + /// choosing a posture is an R22 decision, and it has to be made deliberately rather than by + /// editing the declaration file. + pub fn permission_posture_flags(&self, agent: CLIAgent) -> Vec<&str> { + let Some(declaration) = self.agents.get(&agent) else { + return Vec::new(); + }; + declaration + .flags + .iter() + .filter(|(_, declared)| declared.permission_posture) + .map(|(name, _)| name.as_str()) + .collect() + } + + /// The shell command that reattaches `agent` to `identifier`, carrying whichever of + /// `flags` still validate and `posture` still admits. + /// + /// Returns `None` when the agent is undeclared or the resume pointer itself fails + /// its declared shape: without a usable pointer there is no invocation to salvage. A + /// [`PermissionPosture::Drop`] never costs the resume, only the elevation. + pub fn build_resume_command( + &self, + agent: CLIAgent, + identifier: &str, + flags: &[RecordedFlag], + posture: PermissionPosture, + ) -> Option { + let declaration = self.agents.get(&agent)?; + if !declaration.identifier.accepts(identifier) { + return None; + } + + let mut command = declaration.binary.to_owned(); + if let ResumeInvocation::Subcommand(subcommand) = &declaration.resume { + command.push(' '); + command.push_str(subcommand); + } + + // Flags go ahead of the identifier because some agents take a trailing prompt + // positional, which would otherwise swallow everything after it. + for flag in flags { + let Some(name) = declaration.canonical_name(&flag.name) else { + continue; + }; + let Some(declared) = declaration.flags.get(name) else { + continue; + }; + // R22: an elevation the user chose is theirs to keep only while the observation + // behind it is recent. Past the window the flag goes and the resume stays. + if declared.permission_posture && posture == PermissionPosture::Drop { + continue; + } + match (declared.shape, flag.value.as_deref()) { + (ValueShape::Boolean, None) => { + command.push(' '); + command.push_str(name); + } + (ValueShape::Boolean, Some(_)) => continue, + (_, Some(value)) if declared.accepts(value) => { + let Some(quoted) = shell_quote(value) else { + continue; + }; + command.push(' '); + command.push_str(name); + command.push(' '); + command.push_str("ed); + } + (_, _) => continue, + } + } + + if let ResumeInvocation::Flag(flag) = &declaration.resume { + command.push(' '); + command.push_str(flag); + } + command.push(' '); + command.push_str(&shell_quote(identifier)?); + command.push_str(" # "); + command.push_str(RESUME_HISTORY_MARKER); + Some(command) + } +} + +impl AgentDeclaration { + fn build( + name: &str, + binary: &'static str, + raw: RawAgent, + ) -> Result { + let resume = ResumeInvocation::build(name, raw.resume)?; + let invalid_identifier = |reason| DeclarationError::Value { + agent: name.to_owned(), + key: "identifier".to_owned(), + reason, + }; + if !raw.identifier.aliases.is_empty() { + return Err(invalid_identifier("an identifier is not spelled as a flag")); + } + if raw.identifier.permission_posture { + return Err(invalid_identifier( + "a session pointer chooses no permission posture", + )); + } + let identifier = ValueDeclaration::build(name, "identifier", raw.identifier)?; + if identifier.shape == ValueShape::Boolean { + return Err(invalid_identifier( + "a session identifier is a value, not a bare flag", + )); + } + + let mut flags = HashMap::with_capacity(raw.flags.len()); + let mut aliases = HashMap::new(); + for (flag, value) in raw.flags { + if !is_flag_spelling(&flag) { + return Err(DeclarationError::Value { + agent: name.to_owned(), + key: flag, + reason: "an allowlist entry has to be a `--flag`", + }); + } + for alias in &value.aliases { + if !is_flag_spelling(alias) { + return Err(DeclarationError::Value { + agent: name.to_owned(), + key: alias.clone(), + reason: "an alias has to be a flag spelling", + }); + } + aliases.insert(alias.clone(), flag.clone()); + } + flags.insert(flag.clone(), ValueDeclaration::build(name, &flag, value)?); + } + + Ok(AgentDeclaration { + binary, + resume, + identifier, + flags, + aliases, + }) + } +} + +impl ResumeInvocation { + fn build(agent: &str, raw: RawResume) -> Result { + let invalid = |reason| DeclarationError::Invocation { + agent: agent.to_owned(), + reason, + }; + match (raw.form, raw.flag, raw.subcommand) { + (ResumeForm::Flag, Some(flag), None) if is_flag_spelling(&flag) => { + Ok(ResumeInvocation::Flag(flag)) + } + (ResumeForm::Flag, Some(_), None) => Err(invalid("the flag form needs a `--flag`")), + (ResumeForm::Flag, _, _) => { + Err(invalid("the flag form takes a `flag` and nothing else")) + } + (ResumeForm::Subcommand, None, Some(subcommand)) + if ValueShape::BareToken.accepts(&subcommand, MAX_INVOCATION_LENGTH) => + { + Ok(ResumeInvocation::Subcommand(subcommand)) + } + (ResumeForm::Subcommand, None, Some(_)) => { + Err(invalid("the subcommand is not a bare word")) + } + (ResumeForm::Subcommand, _, _) => Err(invalid( + "the subcommand form takes a `subcommand` and nothing else", + )), + } + } +} + +impl ValueDeclaration { + fn build(agent: &str, key: &str, raw: RawValue) -> Result { + let invalid = |reason| DeclarationError::Value { + agent: agent.to_owned(), + key: key.to_owned(), + reason, + }; + let max_length = match (raw.shape, raw.max_length) { + (ValueShape::Boolean, None) => 0, + (ValueShape::Boolean, Some(_)) => { + return Err(invalid("a boolean flag has no value to bound")); + } + (_, Some(max_length)) if max_length > 0 => max_length, + (_, Some(_)) => return Err(invalid("a value bound of zero admits nothing")), + (_, None) => return Err(invalid("a value needs a length bound")), + }; + Ok(ValueDeclaration { + shape: raw.shape, + max_length, + permission_posture: raw.permission_posture, + }) + } +} + +/// Longest invocation fragment the declaration file may supply. +const MAX_INVOCATION_LENGTH: usize = 64; + +fn is_flag_spelling(candidate: &str) -> bool { + candidate.starts_with("--") + && ValueShape::BareToken.accepts(&candidate[2..], MAX_INVOCATION_LENGTH) +} + +/// `command` in the form the pane's shell keeps out of its history file. +/// +/// R18: a resume must leave no trace in the user's history, and [`RESUME_HISTORY_MARKER`] only +/// achieves that where the shell can be told which command patterns to omit. Fish cannot, so it +/// gets the mechanism it does have: a leading space, which fish omits from history as default, +/// non-configurable behavior. A shell Warp cannot identify keeps the marker alone rather than a +/// space some shells would pass straight through to the command. +pub fn history_suppressed_resume_command(shell_type: Option, command: String) -> String { + match shell_type { + Some(ShellType::Fish) => format!(" {command}"), + _ => command, + } +} + +/// Wraps `value` in single quotes, which every shell Warp bootstraps treats as fully +/// literal. Refuses a value containing a single quote, which is the only character +/// that could end the wrapping: the shapes already reject it, so this is the second +/// of two independent barriers rather than the first. +fn shell_quote(value: &str) -> Option { + (!value.contains('\'')).then(|| format!("'{value}'")) +} + +#[cfg(test)] +#[path = "cli_agent_resume_tests.rs"] +mod tests; diff --git a/app/src/terminal/cli_agent_resume_tests.rs b/app/src/terminal/cli_agent_resume_tests.rs new file mode 100644 index 00000000000..f7c87e9b8b6 --- /dev/null +++ b/app/src/terminal/cli_agent_resume_tests.rs @@ -0,0 +1,751 @@ +use std::time::Duration; + +use chrono::{NaiveDate, NaiveDateTime, TimeDelta}; +use enum_iterator::all; + +use super::*; + +const SESSION_ID: &str = "8f0b1c2d-3e4f-5061-7283-94a5b6c7d8e9"; + +fn declarations() -> &'static ResumeDeclarations { + ResumeDeclarations::embedded() +} + +fn flag(name: &str, value: Option<&str>) -> RecordedFlag { + RecordedFlag { + name: name.to_owned(), + value: value.map(str::to_owned), + } +} + +fn claude_command(flags: &[RecordedFlag]) -> String { + declarations() + .build_resume_command( + CLIAgent::Claude, + SESSION_ID, + flags, + PermissionPosture::Carry, + ) + .expect("Claude is declared and the identifier is well formed") +} + +#[test] +fn embedded_declarations_parse_and_name_only_known_agents() { + let declarations = declarations(); + let declared: Vec = all::() + .filter(|agent| declarations.supports(*agent)) + .collect(); + + assert!( + declared.contains(&CLIAgent::Claude), + "expected Claude to be declared, got {declared:?}" + ); + assert!( + declared.contains(&CLIAgent::Codex), + "expected Codex to be declared, got {declared:?}" + ); + assert!( + !declared.contains(&CLIAgent::Unknown), + "Unknown has no binary and must never be declared" + ); + for agent in &declared { + assert!( + !agent.command_prefix().is_empty(), + "{agent:?} is declared but has no command prefix to resume with" + ); + } +} + +#[test] +fn agents_without_a_verified_resume_are_undeclared() { + for agent in [CLIAgent::Gemini, CLIAgent::WarpTui] { + assert!( + !declarations().supports(agent), + "{agent:?} must stay undeclared" + ); + assert_eq!( + declarations().build_resume_command(agent, SESSION_ID, &[], PermissionPosture::Carry), + None, + "{agent:?} must not build any invocation" + ); + } +} + +#[test] +fn unknown_agent_name_is_rejected() { + let contents = r#" +[agents.Claud] +resume = { form = "flag", flag = "--resume" } +identifier = { shape = "bare_token", max_length = 128 } +"#; + + assert!( + ResumeDeclarations::parse(contents).is_err(), + "a misspelled agent name must be rejected, not silently ignored" + ); +} + +#[test] +fn agent_without_a_binary_is_rejected() { + let contents = r#" +[agents.Unknown] +resume = { form = "flag", flag = "--resume" } +identifier = { shape = "bare_token", max_length = 128 } +"#; + + assert!( + ResumeDeclarations::parse(contents).is_err(), + "an agent with no command prefix has nothing to resume with" + ); +} + +#[test] +fn malformed_resume_shape_is_rejected() { + let identifier = r#"identifier = { shape = "bare_token", max_length = 128 }"#; + let malformed = [ + // Unknown invocation form. + r#"resume = { form = "environment_variable", name = "SESSION" }"#, + // Flag form without the flag it must pass. + r#"resume = { form = "flag" }"#, + // Subcommand form without the subcommand it must run. + r#"resume = { form = "subcommand" }"#, + // Flag form carrying a subcommand it would never use. + r#"resume = { form = "flag", flag = "--resume", subcommand = "resume" }"#, + // A flag that is not a flag. + r#"resume = { form = "flag", flag = "resume" }"#, + // An invocation fragment that would reach the shell unquoted. + r#"resume = { form = "subcommand", subcommand = "resume; rm -rf /" }"#, + ]; + + for resume in malformed { + let contents = format!("[agents.Claude]\n{resume}\n{identifier}\n"); + assert!( + ResumeDeclarations::parse(&contents).is_err(), + "expected rejection of malformed invocation: {resume}" + ); + } +} + +#[test] +fn malformed_value_declaration_is_rejected() { + let resume = r#"resume = { form = "flag", flag = "--resume" }"#; + let identifier = r#"identifier = { shape = "bare_token", max_length = 128 }"#; + let malformed = [ + // A value shape with no length bound. + format!("[agents.Claude]\n{resume}\nidentifier = {{ shape = \"bare_token\" }}\n"), + // An identifier that carries no value at all. + format!("[agents.Claude]\n{resume}\nidentifier = {{ shape = \"boolean\" }}\n"), + // A boolean flag with a length bound it can never use. + format!( + "[agents.Claude]\n{resume}\n{identifier}\n[agents.Claude.flags]\n\ + \"--strict-mcp-config\" = {{ shape = \"boolean\", max_length = 8 }}\n" + ), + // A flag whose value shape has no length bound. + format!( + "[agents.Claude]\n{resume}\n{identifier}\n[agents.Claude.flags]\n\ + \"--model\" = {{ shape = \"bare_token\" }}\n" + ), + // An allowlist entry that is not a flag. + format!( + "[agents.Claude]\n{resume}\n{identifier}\n[agents.Claude.flags]\n\ + \"model\" = {{ shape = \"bare_token\", max_length = 8 }}\n" + ), + // An unknown value shape. + format!( + "[agents.Claude]\n{resume}\n{identifier}\n[agents.Claude.flags]\n\ + \"--model\" = {{ shape = \"anything\", max_length = 8 }}\n" + ), + // An alias for the session identifier, which is never spelled as a flag. + format!( + "[agents.Claude]\n{resume}\n\ + identifier = {{ shape = \"bare_token\", max_length = 128, aliases = [\"--id\"] }}\n" + ), + // An alias that is not a flag spelling. + format!( + "[agents.Claude]\n{resume}\n{identifier}\n[agents.Claude.flags]\n\ + \"--model\" = {{ shape = \"bare_token\", max_length = 8, aliases = [\"model\"] }}\n" + ), + // A key the declaration format does not define. + format!("[agents.Claude]\n{resume}\n{identifier}\nexecutable = \"claude\"\n"), + ]; + + for contents in malformed { + assert!( + ResumeDeclarations::parse(&contents).is_err(), + "expected rejection of malformed declaration:\n{contents}" + ); + } +} + +#[test] +fn flag_form_agent_builds_a_resume_flag_invocation() { + assert_eq!( + claude_command(&[]), + format!("claude --resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}") + ); +} + +#[test] +fn subcommand_form_agent_builds_a_resume_subcommand_invocation() { + assert_eq!( + declarations().build_resume_command( + CLIAgent::Codex, + SESSION_ID, + &[], + PermissionPosture::Carry + ), + Some(format!( + "codex resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}" + )) + ); +} + +/// AE5: nothing beyond the resume pointer when nothing was recorded. +#[test] +fn empty_recorded_set_carries_no_flags() { + let command = claude_command(&[]); + let flag_count = command + .split_whitespace() + .filter(|token| token.starts_with("--")) + .count(); + + assert_eq!(flag_count, 1, "expected only the resume flag in {command}"); +} + +/// AE6: the permission posture rides along when, and only when, it was recorded. +#[test] +fn recorded_permission_bypass_flag_is_carried_verbatim() { + let command = claude_command(&[flag("--dangerously-skip-permissions", None)]); + + assert_eq!( + command, + format!( + "claude --dangerously-skip-permissions --resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}" + ) + ); +} + +#[test] +fn builder_adds_no_flag_of_its_own() { + let command = claude_command(&[]); + let codex_command = declarations() + .build_resume_command(CLIAgent::Codex, SESSION_ID, &[], PermissionPosture::Carry) + .expect("Codex is declared"); + + for unwanted in [ + "--dangerously-skip-permissions", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + "--fork-session", + "--session-id", + "--permission-mode", + "--model", + ] { + assert!( + !command.contains(unwanted), + "{command} must not contain {unwanted}" + ); + assert!( + !codex_command.contains(unwanted), + "{codex_command} must not contain {unwanted}" + ); + } +} + +/// AE18: a hostile stored value takes the whole flag down with it and leaves no +/// trace in the built string. +#[test] +fn unsafe_values_drop_the_flag_without_leaking_a_fragment() { + let bare = claude_command(&[]); + let hostile = [ + "sonnet;pwn", + "sonnet$(pwn)", + "sonnet`pwn`", + "sonnet&&pwn", + "sonnet|pwn", + "sonnet*pwn", + "sonnet\npwn", + "sonnet'pwn", + "sonnet pwn", + "sonnet\"pwn\"", + "sonnet>pwn", + "sonnet (NaiveDateTime, NaiveDateTime) { + let now = NaiveDate::from_ymd_opt(2026, 8, 11) + .expect("date should be valid") + .and_hms_opt(21, 0, 0) + .expect("time should be valid"); + (now - TimeDelta::hours(hours), now) +} + +/// AE17: state carrying a permission-bypass flag, last observed outside the freshness window, +/// resumes the same conversation without the bypass. +#[test] +fn stale_recording_resumes_the_same_session_without_its_permission_posture() { + let (observed_at, now) = observed_hours_ago(13); + let posture = PermissionPosture::for_observation(observed_at, now); + + let command = declarations() + .build_resume_command( + CLIAgent::Claude, + SESSION_ID, + &[ + flag("--dangerously-skip-permissions", None), + flag("--model", Some("sonnet")), + ], + posture, + ) + .expect("a stale posture must still produce a resume"); + + assert_eq!( + command, + format!("claude --model 'sonnet' --resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}"), + "the pane still resumes its conversation, just without the elevation" + ); +} + +/// AE6: the same recording, observed inside the window, keeps the posture the user chose. +#[test] +fn fresh_recording_keeps_its_permission_posture() { + let (observed_at, now) = observed_hours_ago(11); + let posture = PermissionPosture::for_observation(observed_at, now); + + assert_eq!(posture, PermissionPosture::Carry); + assert_eq!( + declarations().build_resume_command( + CLIAgent::Claude, + SESSION_ID, + &[flag("--dangerously-skip-permissions", None)], + posture, + ), + Some(format!( + "claude --dangerously-skip-permissions --resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}" + )) + ); +} + +/// Both sides of the boundary itself, so the window is a real bound rather than a rounding. +#[test] +fn freshness_window_is_bounded_at_twelve_hours() { + assert_eq!( + PERMISSION_POSTURE_FRESHNESS, + Duration::from_secs(12 * 60 * 60) + ); + + let (at_the_bound, now) = observed_hours_ago(12); + assert_eq!( + PermissionPosture::for_observation(at_the_bound, now), + PermissionPosture::Carry, + "state observed exactly at the bound is still inside the window" + ); + assert_eq!( + PermissionPosture::for_observation(at_the_bound - TimeDelta::seconds(1), now), + PermissionPosture::Drop, + "one second past the bound is outside it" + ); +} + +/// A clock that moved backwards cannot vouch for an age, and an unverifiable age is not a fresh +/// one. Otherwise a stale recording could be revived by moving the machine clock back. +#[test] +fn recording_from_the_future_is_not_treated_as_fresh() { + let (_, now) = observed_hours_ago(0); + + assert_eq!( + PermissionPosture::for_observation(now + TimeDelta::hours(1), now), + PermissionPosture::Drop + ); +} + +/// Posture is a property of the flag, not of whether it carries a value: `--permission-mode` +/// chooses a posture just as much as the bypass switch does. +#[test] +fn stale_recording_drops_a_valued_permission_posture_flag() { + let (observed_at, now) = observed_hours_ago(13); + let posture = PermissionPosture::for_observation(observed_at, now); + + assert_eq!( + declarations().build_resume_command( + CLIAgent::Claude, + SESSION_ID, + &[flag("--permission-mode", Some("bypassPermissions"))], + posture, + ), + Some(format!( + "claude --resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}" + )) + ); +} + +#[test] +fn stale_recording_drops_the_codex_bypass_flag() { + let (observed_at, now) = observed_hours_ago(24); + let posture = PermissionPosture::for_observation(observed_at, now); + + assert_eq!( + declarations().build_resume_command( + CLIAgent::Codex, + SESSION_ID, + &[flag("--dangerously-bypass-approvals-and-sandbox", None)], + posture, + ), + Some(format!( + "codex resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}" + )) + ); +} + +/// The permission-posture set is spelled out here so that adding a flag to the declaration file +/// has to be a deliberate R22 decision rather than a silent one: a new posture flag fails this +/// test until it is acknowledged, and un-marking an existing one fails it too. +#[test] +fn declared_permission_posture_flags_are_exactly_the_acknowledged_ones() { + let mut claude = declarations().permission_posture_flags(CLIAgent::Claude); + claude.sort_unstable(); + assert_eq!( + claude, + vec!["--dangerously-skip-permissions", "--permission-mode"] + ); + + assert_eq!( + declarations().permission_posture_flags(CLIAgent::Codex), + vec!["--dangerously-bypass-approvals-and-sandbox"] + ); +} diff --git a/app/src/terminal/cli_agent_sessions/listener/mod.rs b/app/src/terminal/cli_agent_sessions/listener/mod.rs index a07211893b8..dcc7ffcbb41 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod.rs @@ -32,7 +32,14 @@ trait CLIAgentSessionHandler { /// Decide whether a parsed event should be forwarded to the sessions model. /// Returns the event (possibly transformed) if it should be processed. - fn handle_event(&mut self, event: CLIAgentEvent) -> Option; + /// + /// `recorded_session_id` is the identifier the session currently holds, so + /// handlers can tell a redundant event from one that reports a change. + fn handle_event( + &mut self, + event: CLIAgentEvent, + recorded_session_id: Option<&str>, + ) -> Option; } /// Returns `true` if the given CLI agent has a supported session handler. @@ -82,13 +89,21 @@ fn create_handler(agent: &CLIAgent) -> Option> { } /// Default handler shared by agents whose events need no special filtering -/// beyond skipping the initial `SessionStart`. +/// beyond skipping a `SessionStart` that reports nothing new. struct DefaultSessionListener; impl CLIAgentSessionHandler for DefaultSessionListener { - fn handle_event(&mut self, event: CLIAgentEvent) -> Option { - // Skip session_start events (handled during listener construction) - if event.event == CLIAgentEventType::SessionStart { + fn handle_event( + &mut self, + event: CLIAgentEvent, + recorded_session_id: Option<&str>, + ) -> Option { + // The session_start that created this listener was already applied, but + // a later one carrying a different id means the user started a new + // conversation in this pane and the session must follow it. + if event.event == CLIAgentEventType::SessionStart + && (event.session_id.is_none() || event.session_id.as_deref() == recorded_session_id) + { return None; } @@ -156,7 +171,11 @@ impl CLIAgentSessionHandler for CodexSessionHandler { Self::parse_osc9_text(body) } - fn handle_event(&mut self, event: CLIAgentEvent) -> Option { + fn handle_event( + &mut self, + event: CLIAgentEvent, + _recorded_session_id: Option<&str>, + ) -> Option { Some(event) } } @@ -189,16 +208,21 @@ impl CLIAgentSessionListener { ctx.subscribe_to_model(model_event_dispatcher, move |me, _, event, ctx| { if let ModelEvent::PluggableNotification { title, body } = event { let view_id = me.terminal_view_id; - let plugin_already_active = CLIAgentSessionsModel::as_ref(ctx) - .session(view_id) - .is_some_and(|session| session.received_rich_notification); + let session = CLIAgentSessionsModel::as_ref(ctx).session(view_id); + let plugin_already_active = + session.is_some_and(|session| session.received_rich_notification); + let recorded_session_id = + session.and_then(|session| session.session_context.session_id.clone()); let Some(parsed) = me.inner .try_parse(title.as_deref(), body, plugin_already_active) else { return; }; - if let Some(event) = me.inner.handle_event(parsed) { + if let Some(event) = me + .inner + .handle_event(parsed, recorded_session_id.as_deref()) + { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { sessions_model.update_from_event(view_id, &event, ctx); }); diff --git a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs index 2b6ea356304..9bf28ecf9d8 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs @@ -118,6 +118,55 @@ fn auggie_is_supported() { assert!(is_agent_supported(&CLIAgent::Auggie)); } +/// Builds a `session_start` event as the rich plugin reports it. +fn session_start_event(session_id: Option<&str>) -> CLIAgentEvent { + CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, + v: 1, + agent: CLIAgent::Claude, + event: CLIAgentEventType::SessionStart, + session_id: session_id.map(str::to_owned), + cwd: None, + project: None, + payload: CLIAgentEventPayload::default(), + } +} + +#[test] +fn default_handler_forwards_session_start_with_new_session_id() { + let mut handler = DefaultSessionListener; + let forwarded = handler + .handle_event( + session_start_event(Some("conversation-b")), + Some("conversation-a"), + ) + .expect("a new conversation must reach the sessions model"); + assert_eq!(forwarded.session_id.as_deref(), Some("conversation-b")); +} + +#[test] +fn default_handler_skips_session_start_repeating_recorded_session_id() { + let mut handler = DefaultSessionListener; + assert!( + handler + .handle_event( + session_start_event(Some("conversation-a")), + Some("conversation-a") + ) + .is_none() + ); +} + +#[test] +fn default_handler_skips_session_start_without_session_id() { + let mut handler = DefaultSessionListener; + assert!( + handler + .handle_event(session_start_event(None), Some("conversation-a")) + .is_none() + ); +} + #[test] fn auggie_default_handler_skips_session_start() { let mut handler = DefaultSessionListener; @@ -131,7 +180,7 @@ fn auggie_default_handler_skips_session_start() { project: None, payload: CLIAgentEventPayload::default(), }; - assert!(handler.handle_event(event).is_none()); + assert!(handler.handle_event(event, None).is_none()); } #[test] @@ -147,7 +196,7 @@ fn auggie_default_handler_forwards_stop() { project: None, payload: CLIAgentEventPayload::default(), }; - assert!(handler.handle_event(event).is_some()); + assert!(handler.handle_event(event, None).is_some()); } #[test] @@ -173,7 +222,7 @@ fn pi_default_handler_skips_session_start() { project: None, payload: CLIAgentEventPayload::default(), }; - assert!(handler.handle_event(event).is_none()); + assert!(handler.handle_event(event, None).is_none()); } #[test] @@ -189,7 +238,7 @@ fn pi_default_handler_forwards_stop() { project: None, payload: CLIAgentEventPayload::default(), }; - assert!(handler.handle_event(event).is_some()); + assert!(handler.handle_event(event, None).is_some()); } #[test] @@ -210,7 +259,7 @@ fn droid_default_handler_skips_session_start() { project: None, payload: CLIAgentEventPayload::default(), }; - assert!(handler.handle_event(event).is_none()); + assert!(handler.handle_event(event, None).is_none()); } #[test] @@ -226,7 +275,7 @@ fn droid_default_handler_forwards_stop() { project: None, payload: CLIAgentEventPayload::default(), }; - assert!(handler.handle_event(event).is_some()); + assert!(handler.handle_event(event, None).is_some()); } #[test] @@ -242,7 +291,7 @@ fn droid_default_handler_forwards_permission_request() { project: None, payload: CLIAgentEventPayload::default(), }; - assert!(handler.handle_event(event).is_some()); + assert!(handler.handle_event(event, None).is_some()); } #[test] @@ -255,7 +304,7 @@ fn warp_tui_notifications_are_supported() { .expect("should parse stop"); assert_eq!(parsed_stop.agent, CLIAgent::WarpTui); assert_eq!(parsed_stop.event, CLIAgentEventType::Stop); - assert!(handler.handle_event(parsed_stop).is_some()); + assert!(handler.handle_event(parsed_stop, None).is_some()); } #[test] @@ -269,7 +318,7 @@ fn oh_my_pi_end_to_end_parsing_and_handling() { .expect("should successfully parse session_start payload"); assert_eq!(parsed_start.agent, CLIAgent::OhMyPi); assert_eq!(parsed_start.event, CLIAgentEventType::SessionStart); - assert!(handler.handle_event(parsed_start).is_none()); + assert!(handler.handle_event(parsed_start, None).is_none()); // Test stop payload: proves Stop forwards with CLIAgent::OhMyPi let stop_body = r#"{"v":1,"agent":"omp","event":"stop"}"#; @@ -280,7 +329,7 @@ fn oh_my_pi_end_to_end_parsing_and_handling() { assert_eq!(parsed_stop.event, CLIAgentEventType::Stop); let handled_stop = handler - .handle_event(parsed_stop) + .handle_event(parsed_stop, None) .expect("should forward stop event"); assert_eq!(handled_stop.agent, CLIAgent::OhMyPi); assert_eq!(handled_stop.event, CLIAgentEventType::Stop); diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index 6344278287b..3d4cb4eced8 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -342,6 +342,18 @@ impl CLIAgentSessionsModel { self.sessions.get(&terminal_view_id) } + /// The agent running in this terminal and the newest session identifier it reported, or + /// `None` while no agent is running or none has reported an identifier yet. + /// + /// Deliberately narrower than [`Self::session`]. What Warp records for a restart must carry + /// nothing of the session context but the identifier, and a caller handed only these two + /// cannot reach the prompts, responses, summaries and tool previews stored beside it. + pub fn reported_agent_session(&self, terminal_view_id: EntityId) -> Option<(CLIAgent, &str)> { + let session = self.sessions.get(&terminal_view_id)?; + let session_id = session.session_context.session_id.as_deref()?; + Some((session.agent, session_id)) + } + /// Returns `true` if the rich input editor is currently open for this terminal. pub fn is_input_open(&self, terminal_view_id: EntityId) -> bool { self.sessions diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 7d779263b09..f0166197010 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -295,6 +295,83 @@ fn apply_event_preserves_input_session() { assert_eq!(session.input_state, input_state); } +/// Builds an in-progress Claude session that has already recorded `session_id`. +fn claude_session_with_recorded_id(session_id: &str) -> CLIAgentSession { + CLIAgentSession { + agent: CLIAgent::Claude, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext { + session_id: Some(session_id.to_owned()), + ..Default::default() + }, + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + plugin_version: None, + draft_text: None, + remote_host: None, + custom_command_prefix: None, + received_rich_notification: false, + } +} + +fn claude_event(event: CLIAgentEventType, session_id: Option<&str>) -> CLIAgentEvent { + CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, + v: 1, + agent: CLIAgent::Claude, + event, + session_id: session_id.map(str::to_owned), + cwd: None, + project: None, + payload: CLIAgentEventPayload::default(), + } +} + +#[test] +fn session_start_with_new_id_replaces_recorded_session_id() { + let mut session = claude_session_with_recorded_id("conversation-a"); + + session.apply_event(&claude_event( + CLIAgentEventType::SessionStart, + Some("conversation-b"), + )); + + assert_eq!( + session.session_context.session_id.as_deref(), + Some("conversation-b") + ); +} + +#[test] +fn event_without_session_id_keeps_recorded_session_id() { + let mut session = claude_session_with_recorded_id("conversation-a"); + + session.apply_event(&claude_event(CLIAgentEventType::Stop, None)); + + assert_eq!( + session.session_context.session_id.as_deref(), + Some("conversation-a") + ); +} + +#[test] +fn tool_complete_with_new_id_replaces_recorded_session_id() { + // ToolComplete is discarded for status purposes while the session is not + // blocked, but the identifier it carries must still be recorded. + let mut session = claude_session_with_recorded_id("conversation-a"); + + session.apply_event(&claude_event( + CLIAgentEventType::ToolComplete, + Some("conversation-b"), + )); + + assert_eq!( + session.session_context.session_id.as_deref(), + Some("conversation-b") + ); +} + #[test] fn is_remote_returns_true_when_remote_host_is_set() { let session = CLIAgentSession { diff --git a/app/src/terminal/event.rs b/app/src/terminal/event.rs index 6e5befefe29..3d8bcac86a4 100644 --- a/app/src/terminal/event.rs +++ b/app/src/terminal/event.rs @@ -309,6 +309,11 @@ pub struct UserBlockCompleted { /// `true` if the block was run as a requested command or was part of a CLI subagent interaction. pub was_part_of_agent_interaction: bool, + /// `true` if Warp wrote this command itself with nobody asking for it — today only an agent + /// session resume. Carried separately from [`Self::was_part_of_agent_interaction`], which is + /// derived from the block's `ai_metadata` and is structurally `false` for a resume. + pub was_warp_authored: bool, + /// Time that we started the command grid (i.e. immediately after the user /// hit enter). pub started_at: Option, @@ -321,6 +326,17 @@ pub struct UserBlockCompleted { pub num_output_lines_truncated: u64, } +impl UserBlockCompleted { + /// `true` when a person put this command in the pane, either by typing it or by asking an + /// agent to run it. + /// + /// Everything Warp attributes to the person behind a pane — history, suggestions, one-time + /// dismissals, notifications — keys off this rather than off a user block merely existing. + pub fn was_user_authored(&self) -> bool { + !self.was_part_of_agent_interaction && !self.was_warp_authored + } +} + /// Emitted upon completion of an executor command that goes through the pty, such as the /// InBandCommandExecutor. #[derive(Clone)] diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index e0cb6a57875..06f45417637 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -897,6 +897,14 @@ pub enum CommandExecutionSource { /// A normal command execution request. User, + + /// Warp's own invocation reattaching a restored pane to the agent session it was running + /// before the restart. + /// + /// Neither the user's line nor an agent's: nobody asked for it, so it must leave none of the + /// traces a first user command in a pane leaves. + AgentSessionResume, + /// A command dispatched by the queued-prompts panel. It should execute like a user command but /// must not treat the current editor contents as the submitted command. QueuedCommand, @@ -921,6 +929,11 @@ impl CommandExecutionSource { ) } + /// Whether Warp wrote this command itself, with no person behind it. + pub fn is_warp_authored(&self) -> bool { + matches!(self, CommandExecutionSource::AgentSessionResume) + } + pub fn should_preserve_input(&self) -> bool { matches!( self, @@ -6994,7 +7007,7 @@ impl Input { // If the last block was empty, don't create any suggestions. // Also don't create suggestions for requested commands part of an agent mode conversation. - if block_completed.command.is_empty() || block_completed.was_part_of_agent_interaction { + if block_completed.command.is_empty() || !block_completed.was_user_authored() { return; } @@ -7495,6 +7508,23 @@ impl Input { } } + /// Runs the invocation that reattaches a restored pane to the agent session it had before the + /// restart. + /// + /// The command arrives as data rather than through the editor: it is Warp's line, not a draft + /// the user is holding, so it never touches their buffer on the way to the shell. + pub(crate) fn execute_agent_session_resume( + &mut self, + command: &str, + ctx: &mut ViewContext, + ) -> bool { + self.try_execute_command_from_source( + command, + CommandExecutionSource::AgentSessionResume, + ctx, + ) + } + /// Executes a command drained or sent immediately from the queued-prompts panel and keeps the /// remaining queue paused until the command's terminal block finishes. pub(crate) fn execute_queued_command( @@ -7538,7 +7568,11 @@ impl Input { ctx: &mut ViewContext, ) -> bool { if let CanExecuteCommand::No(reason) = self.can_execute_command(ctx) { - if reason.is_existing_active_command() { + // A resume is Warp's own line rather than a submission the user is waiting on, and a + // pane that cannot run it has to look exactly like a pane that was never offered one. + let is_internal_invocation = + matches!(source, CommandExecutionSource::AgentSessionResume); + if reason.is_existing_active_command() && !is_internal_invocation { const MAX_COMMAND_LENGTH: usize = 43; let truncated_command = truncate_from_end(command, MAX_COMMAND_LENGTH); @@ -7559,6 +7593,8 @@ impl Input { return false; } + let is_warp_authored = source.is_warp_authored(); + // Save the zero state next command state before clearing it. let zerostate_next_command_suggestion_info = self .next_command_model @@ -7627,7 +7663,11 @@ impl Input { { // Skip any empty blocks created by the user. Keep the last zero-state autosuggestion // until the user executes a command. + // + // A Warp-authored command is excluded outright: this arm reports the executed command + // text, and a resume invocation is a line the user never typed. if !command.is_empty() + && !is_warp_authored && let Some(ZeroStateSuggestionInfo { request, response, @@ -7673,23 +7713,27 @@ impl Input { // Reset state for whether the user accepted the intelligent autosuggestion. self.was_intelligent_autosuggestion_accepted = false; - self.tips_completed.update(ctx, |tips, ctx| { - mark_feature_used_and_write_to_user_defaults( - Tip::Hint(TipHint::CreateBlock), - tips, - ctx, - ); - ctx.notify(); - }); - - if !command.is_empty() { - IgnoredSuggestionsModel::handle(ctx).update(ctx, |model, ctx| { - model.remove_ignored_suggestion( - command.to_string(), - SuggestionType::ShellCommand, + // R24: the one-time state below is what Warp spends on a user's first command in a + // pane. A Warp-authored command has no user behind it, so it spends none of it. + if !is_warp_authored { + self.tips_completed.update(ctx, |tips, ctx| { + mark_feature_used_and_write_to_user_defaults( + Tip::Hint(TipHint::CreateBlock), + tips, ctx, ); + ctx.notify(); }); + + if !command.is_empty() { + IgnoredSuggestionsModel::handle(ctx).update(ctx, |model, ctx| { + model.remove_ignored_suggestion( + command.to_string(), + SuggestionType::ShellCommand, + ctx, + ); + }); + } } self.start_block_and_write_command_to_pty(command, source, ctx); @@ -15147,8 +15191,9 @@ impl Input { &self.model.lock(), ctx, ); - // Only clear the input buffer for user-executed commands, not agent-executed ones. - let should_clear_buffer = !user_block.was_part_of_agent_interaction + // Only clear the input buffer for user-executed commands, not agent-executed ones and + // not a resume Warp wrote itself. + let should_clear_buffer = user_block.was_user_authored() && !cloud_setup_pre_first_exchange && !self.has_queued_command_in_flight(ctx); let latest_block_id = self.model.lock().block_list().active_block_id().clone(); @@ -15392,7 +15437,9 @@ impl Input { workflow_id, session_id, workflow_command, - should_add_command_to_history: true, + // KTD7 layer one: a command Warp wrote itself is not the user's history. This same + // flag gates the persisted commands table in `terminal_manager_util`. + should_add_command_to_history: !source.is_warp_authored(), source, }))); end_trace!(); diff --git a/app/src/terminal/input_tests.rs b/app/src/terminal/input_tests.rs index 083f32dfffb..c3bf9594307 100644 --- a/app/src/terminal/input_tests.rs +++ b/app/src/terminal/input_tests.rs @@ -106,7 +106,9 @@ use crate::terminal::writeable_pty::command_history::update_command_history; use crate::test_util::settings::initialize_settings_for_tests; use crate::themes::theme::AnsiColorIdentifier; use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; -use crate::workspace::{ActiveSession, OneTimeModalModel, ToastStack, WorkspaceRegistry}; +use crate::workspace::{ + ActiveSession, OneTimeModalModel, ToastStack, ToastStackEvent, WorkspaceRegistry, +}; use crate::workspaces::team_tester::TeamTesterStatus; use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_workspaces::UserWorkspaces; @@ -1810,6 +1812,7 @@ fn queued_command_completion_preserves_draft() { output_truncated: String::new(), output_truncated_with_obfuscated_secrets: String::new(), was_part_of_agent_interaction: false, + was_warp_authored: false, started_at: None, num_output_lines: 0, num_output_lines_truncated: 0, @@ -9639,3 +9642,306 @@ fn upload_files_then_submit_cloud_followup_restores_input_on_upload_error() { ); }); } + +/// The invocation a restored Claude pane would run, marker and all. +const RESUME_INVOCATION: &str = "claude --resume 'session-1' # warp_resume_agent_session"; + +/// Records every command the input hands to the shell, together with the two facts a resume has +/// to differ on: whether Warp's history is asked to keep it, and who authored it. +fn observe_executed_commands( + input: &ViewHandle, + app: &mut App, +) -> Rc>> { + let executed = Rc::new(RefCell::new(Vec::new())); + let executed_for_subscription = executed.clone(); + app.update(|ctx| { + ctx.subscribe_to_view(input, move |_, event: &super::Event, _| { + if let super::Event::ExecuteCommand(event) = event { + executed_for_subscription.borrow_mut().push(( + event.command.clone(), + event.should_add_command_to_history, + event.source.is_warp_authored(), + )); + } + }); + }); + executed +} + +/// AE10/R18: the resume reaches the shell but never Warp's own history, which is the same flag +/// that gates the persisted commands table (`terminal_manager_util`). +#[test] +fn agent_session_resume_runs_without_entering_warps_history() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let session_info = SessionInfo::new_for_test(); + let session_id = session_info.session_id; + let terminal = + add_window_with_bootstrapped_terminal(&mut app, None, Some(session_info)).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + let executed = observe_executed_commands(&input, &mut app); + + input.update(&mut app, |input, ctx| { + input.execute_agent_session_resume(RESUME_INVOCATION, ctx); + }); + + assert_eq!( + executed.borrow().as_slice(), + [(RESUME_INVOCATION.to_owned(), false, true)], + "the resume has to run, and to run as Warp's own line kept out of history" + ); + History::handle(&app).read(&app, |history, _| { + assert!( + history + .commands(session_id) + .is_some_and(|commands| commands.is_empty()), + "no resume invocation may reach the session's command history" + ); + }); + }); +} + +/// AE14/R24: the one-time state Warp spends on a user's first command in a pane — the +/// create-block tip and their dismissed suggestions — survives a resume untouched. +#[test] +fn agent_session_resume_spends_no_first_command_state() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + let executed = observe_executed_commands(&input, &mut app); + + IgnoredSuggestionsModel::handle(&app).update(&mut app, |model, ctx| { + model.add_ignored_suggestion( + RESUME_INVOCATION.to_owned(), + crate::suggestions::ignored_suggestions_model::SuggestionType::ShellCommand, + ctx, + ); + }); + + input.update(&mut app, |input, ctx| { + input.execute_agent_session_resume(RESUME_INVOCATION, ctx); + }); + + assert_eq!( + executed.borrow().len(), + 1, + "the resume has to have run for its silence to mean anything" + ); + let tips = input.read(&app, |input, _| input.tips_completed.clone()); + tips.read(&app, |tips, _| { + assert!( + !tips + .features_used + .contains(&Tip::Hint(TipHint::CreateBlock)), + "a resume is not the user creating their first block" + ); + }); + IgnoredSuggestionsModel::handle(&app).read(&app, |model, _| { + assert!( + model.is_ignored( + RESUME_INVOCATION, + crate::suggestions::ignored_suggestions_model::SuggestionType::ShellCommand, + ), + "a resume must not un-ignore a suggestion the user dismissed" + ); + }); + }); +} + +/// The command-text telemetry arm carries the executed command verbatim, so it must not fire for +/// a line the user never typed. `last_intelligent_autosuggestion_result` is set inside that same +/// arm, which makes it the observable that says whether the arm ran. +#[test] +fn agent_session_resume_does_not_report_command_text_telemetry() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + + let seed_prediction = |app: &mut App| { + let next_command_model = input.read(app, |input, _| input.next_command_model.clone()); + next_command_model.update(app, |model, _| { + model.set_zero_state_suggestion_info_for_test(ZeroStateSuggestionInfo { + request: Box::default(), + response: Default::default(), + request_duration_ms: 1, + is_from_ai: false, + history_based_autosuggestion_state: Default::default(), + }); + }); + }; + + seed_prediction(&mut app); + input.update(&mut app, |input, ctx| { + input.execute_agent_session_resume(RESUME_INVOCATION, ctx); + }); + input.read(&app, |input, _| { + assert!( + input.last_intelligent_autosuggestion_result.is_none(), + "a resume must not reach the arm that sends the executed command text" + ); + }); + + // The same seeded prediction and a command the user typed: the arm still fires, so the + // silence above is the source and not a dead code path. + seed_prediction(&mut app); + input.update(&mut app, |input, ctx| { + input.try_execute_command("echo hi", ctx); + }); + input.read(&app, |input, _| { + assert!( + input.last_intelligent_autosuggestion_result.is_some(), + "a user command still reports its prediction outcome" + ); + }); + }); +} + +/// R7/KTD10: an armed resume waits on the gate a launch-config command waits on. Nothing reaches +/// the shell until the pane says it is ready for a command. +#[test] +fn armed_agent_session_resume_sends_nothing_until_the_pane_is_ready() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + let executed = observe_executed_commands(&input, &mut app); + + terminal.update(&mut app, |view, _| { + view.arm_agent_session_resume(RESUME_INVOCATION.to_owned()); + }); + assert!( + executed.borrow().is_empty(), + "arming alone must not write to the pty" + ); + + terminal.update(&mut app, |view, ctx| { + view.execute_pending_command((), ctx); + }); + assert_eq!( + executed.borrow().len(), + 1, + "the ready gate is what releases the invocation" + ); + + // R7: the invocation is everything Warp sends. No prompt follows it, and running the gate + // again does not repeat it. + terminal.update(&mut app, |view, ctx| { + view.execute_pending_command((), ctx); + }); + assert_eq!( + executed.borrow().as_slice(), + [(RESUME_INVOCATION.to_owned(), false, true)], + "Warp sends the invocation and nothing else" + ); + }); +} + +/// AE11: a pane already holding the user's text is not a pane Warp may write into. The two +/// commands must never be merged, and the draft must survive. +#[test] +fn agent_session_resume_refuses_a_pane_holding_user_text() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + let executed = observe_executed_commands(&input, &mut app); + + input.update(&mut app, |input, ctx| { + input.replace_buffer_content("git status", ctx); + }); + terminal.update(&mut app, |view, ctx| { + view.arm_agent_session_resume(RESUME_INVOCATION.to_owned()); + view.execute_pending_command((), ctx); + }); + + assert!( + executed.borrow().is_empty(), + "a pane with a draft in it runs neither the resume nor a merge of the two" + ); + assert_eq!( + input.read(&app, |input, ctx| input.buffer_text(ctx)), + "git status", + "the user's draft must be left exactly as they left it" + ); + }); +} + +/// AE11: the refusal is permanent. A user who typed and then cleared the buffer has still made +/// the pane theirs, so the injection is dropped rather than deferred. +#[test] +fn agent_session_resume_refuses_a_pane_the_user_has_typed_into() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + let executed = observe_executed_commands(&input, &mut app); + + terminal.update(&mut app, |view, _| { + view.arm_agent_session_resume(RESUME_INVOCATION.to_owned()); + }); + input.update(&mut app, |input, ctx| { + input.user_insert("l", ctx); + }); + input.update(&mut app, |input, ctx| { + input.clear_buffer_and_reset_undo_stack(ctx); + }); + terminal.update(&mut app, |view, ctx| { + view.execute_pending_command((), ctx); + }); + + assert!( + executed.borrow().is_empty(), + "a pane the user has typed into is theirs, empty buffer or not" + ); + }); +} + +/// A launch-config command queue lands in the same buffer a draft would. The resume yields to it +/// rather than concatenating onto it, and the queued command still runs on its own. +#[test] +fn agent_session_resume_yields_to_a_pending_launch_config_command() { + App::test((), |mut app| async move { + initialize_app(&mut app); + + let terminal = add_window_with_bootstrapped_terminal(&mut app, None, None).await; + let input = terminal.read(&app, |view, _| view.input().clone()); + let executed = observe_executed_commands(&input, &mut app); + + let toasts = Rc::new(RefCell::new(Vec::::new())); + let toasts_for_subscription = toasts.clone(); + app.update(|ctx| { + let toast_stack = ToastStack::handle(ctx); + ctx.subscribe_to_model(&toast_stack, move |_, event: &ToastStackEvent, _| { + if let ToastStackEvent::AddEphemeralToast { toast, .. } = event { + toasts_for_subscription + .borrow_mut() + .push(toast.main_text().to_owned()); + } + }); + }); + + terminal.update(&mut app, |view, ctx| { + view.arm_agent_session_resume(RESUME_INVOCATION.to_owned()); + view.set_pending_command_queue(vec!["echo setup".to_owned()], ctx); + view.execute_pending_command((), ctx); + }); + + assert_eq!( + executed.borrow().as_slice(), + [("echo setup".to_owned(), true, false)], + "the queued setup command runs alone, unmerged and un-rewritten" + ); + assert!( + toasts.borrow().is_empty(), + "yielding is not an error the user has to be told about" + ); + }); +} diff --git a/app/src/terminal/mod.rs b/app/src/terminal/mod.rs index 177ac432d13..c850c1cbae6 100644 --- a/app/src/terminal/mod.rs +++ b/app/src/terminal/mod.rs @@ -90,6 +90,7 @@ pub use writeable_pty::{PtyIntent, PtyIntentEvent, TerminalSurface}; pub mod wsl; pub mod cli_agent; +pub mod cli_agent_resume; pub use cli_agent::CLIAgent; pub(crate) mod cli_agent_sessions; diff --git a/app/src/terminal/model/block.rs b/app/src/terminal/model/block.rs index fc4899f7593..5157b271695 100644 --- a/app/src/terminal/model/block.rs +++ b/app/src/terminal/model/block.rs @@ -361,6 +361,9 @@ pub struct Block { /// Blocklist Env var metadata associated with this block, if any. env_var_metadata: Option, + /// `true` when Warp itself wrote this block's command. + is_warp_authored: bool, + /// Represents the 'interaction mode' for a command block with respect to the agent. /// /// See doc comment on [`InteractionMode`] for detailed explanation of semantics. @@ -600,6 +603,7 @@ impl From<&Block> for BlockType { output_truncated, output_truncated_with_obfuscated_secrets, was_part_of_agent_interaction: block.agent_interaction_metadata().is_some(), + was_warp_authored: block.is_warp_authored(), started_at: block.command_start_time(), num_output_lines: block.output_grid().len() as u64, num_output_lines_truncated: block @@ -1009,6 +1013,7 @@ impl Block { shell_host: None, is_for_in_band_command: false, env_var_metadata: None, + is_warp_authored: false, interaction_mode: InteractionMode::default(), block_banner: None, ignore_next_rprompt: false, @@ -1209,6 +1214,19 @@ impl Block { self.env_var_metadata.as_ref() } + /// `true` when Warp itself wrote this block's command with nobody asking for it. + /// + /// Distinct from [`Self::agent_interaction_metadata`], which marks a command an agent ran on + /// the user's behalf: there the user still started the interaction, so the command counts as + /// theirs. A Warp-authored command has no person behind it at all. + pub fn is_warp_authored(&self) -> bool { + self.is_warp_authored + } + + pub fn set_warp_authored(&mut self) { + self.is_warp_authored = true; + } + pub fn set_env_var_metadata(&mut self, env_var_metadata: BlocklistEnvVarMetadata) { self.env_var_metadata = Some(env_var_metadata); } diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 1bdd0d35eaf..7df47ec1fc0 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -1674,6 +1674,16 @@ impl TerminalModel { self.start_command_execution_for_kind(CommandStartKind::UserOrQueued) } + /// Starts the active block for a command Warp wrote itself, marking it so that nothing + /// downstream reads it as the user's own line. + pub fn start_command_execution_as_warp_authored(&mut self) -> StartCommandOutcome { + let outcome = self.start_command_execution_for_kind(CommandStartKind::UserOrQueued); + if outcome.is_accepted() { + self.block_list.active_block_mut().set_warp_authored(); + } + outcome + } + pub fn start_command_execution_from_env_var_collection( &mut self, env_var_metadata: BlocklistEnvVarMetadata, diff --git a/app/src/terminal/model/terminal_model_tests.rs b/app/src/terminal/model/terminal_model_tests.rs index c8537970794..0b901360461 100644 --- a/app/src/terminal/model/terminal_model_tests.rs +++ b/app/src/terminal/model/terminal_model_tests.rs @@ -13,6 +13,7 @@ use warpui::text::{SelectionType, str_to_byte_vec}; use super::*; use crate::ai::agent::conversation::AIConversationId; use crate::terminal::color; +use crate::terminal::event::BlockType; use crate::terminal::event_listener::ChannelEventListener; use crate::terminal::model::ObfuscateSecrets; use crate::terminal::model::ansi::{CompletionMetadata, Handler, Processor}; @@ -2259,3 +2260,48 @@ fn cloud_mode_setup_phase_ended_does_not_emit_when_not_sharing() { let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); assert!(events.is_empty()); } + +/// KTD7 layer two: a source variant cannot reach `was_part_of_agent_interaction`, which is +/// derived from `ai_metadata` and is structurally `false` for a resume. The block carries its own +/// marker so that everything reading a completed user block can tell Warp's line from the user's. +#[test] +fn warp_authored_command_start_marks_its_block_as_not_the_users() { + let user_block_type = { + let mut terminal = TerminalModel::mock(None, None); + terminal.block_list_mut().set_bootstrapped(); + assert_eq!( + terminal.start_command_execution(), + StartCommandOutcome::Accepted + ); + assert!(!terminal.block_list().active_block().is_warp_authored()); + BlockType::from(terminal.block_list().active_block()) + }; + let BlockType::User(user_block) = user_block_type else { + panic!("a started command block completes as a user block"); + }; + assert!( + user_block.was_user_authored(), + "a command the user submitted is theirs" + ); + + let mut terminal = TerminalModel::mock(None, None); + terminal.block_list_mut().set_bootstrapped(); + assert_eq!( + terminal.start_command_execution_as_warp_authored(), + StartCommandOutcome::Accepted + ); + assert!(terminal.block_list().active_block().is_warp_authored()); + + let BlockType::User(resume_block) = BlockType::from(terminal.block_list().active_block()) + else { + panic!("a resume still starts an ordinary user block"); + }; + assert!( + !resume_block.was_part_of_agent_interaction, + "a resume carries no agent interaction metadata, which is why the marker is needed" + ); + assert!( + !resume_block.was_user_authored(), + "nobody authored the resume; Warp did" + ); +} diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index b4b6dbc2f56..0b20a4b02b5 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -388,6 +388,7 @@ use crate::terminal::block_list_viewport::{ ScrollState, ViewportState, }; use crate::terminal::bootstrap::init_subshell_command; +use crate::terminal::cli_agent_resume::history_suppressed_resume_command; use crate::terminal::cli_agent_sessions::event::{ CLI_AGENT_NOTIFICATION_SENTINEL, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, parse_event, @@ -2577,6 +2578,12 @@ pub struct TerminalView { /// Commands that should run as separate blocks after the active pending /// command finishes successfully. pending_command_queue: VecDeque, + /// The invocation that reattaches this restored pane to the agent session it was running + /// before the restart, held until the shell is ready to take a command. + /// + /// Dropped rather than deferred the moment the pane stops being untouched, because a pane the + /// user has started using is no longer one Warp may write a line into. + pending_agent_session_resume: Option, /// When true, enter agent view after pending setup commands complete /// (i.e. after `PendingCommandCompleted` is emitted). Set by /// `pane_tree_from_template_recursive` when a tab config has both @@ -4322,6 +4329,7 @@ impl TerminalView { is_login_shell_bootstrapped: false, awaiting_pending_command_completion: false, pending_command_queue: Default::default(), + pending_agent_session_resume: None, enter_agent_view_after_pending_commands: false, slow_bootstrap_banner, is_slow_bootstrap_banner_open: false, @@ -11132,7 +11140,12 @@ impl TerminalView { } } - fn on_user_block_completed(&mut self, block_id: &BlockId, ctx: &mut ViewContext) { + fn on_user_block_completed( + &mut self, + block_id: &BlockId, + was_user_authored: bool, + ctx: &mut ViewContext, + ) { self.model.lock().end_notify_on_ssh_login_complete(); // If the block that just ended was an agent-requested long running command for which the user took over control, @@ -11180,8 +11193,10 @@ impl TerminalView { }); } - // Hide telemetry banner forever after first block user executes. - if FeatureFlag::GlobalAIAnalyticsBanner.is_enabled() + // Hide telemetry banner forever after first block user executes. R24: a resume Warp wrote + // itself is not that block, and must not spend a one-time dismissal on the user's behalf. + if was_user_authored + && FeatureFlag::GlobalAIAnalyticsBanner.is_enabled() && !GeneralSettings::as_ref(ctx) .telemetry_banner_dismissed .value() @@ -11905,8 +11920,13 @@ impl TerminalView { ); } - if let BlockType::User(_) = &block_completed_event.block_type { - self.on_user_block_completed(&block_completed_event.block_id, ctx); + if let BlockType::User(user_block) = &block_completed_event.block_type { + let was_user_authored = user_block.was_user_authored(); + self.on_user_block_completed( + &block_completed_event.block_id, + was_user_authored, + ctx, + ); } // Clear any stale warpify footer so it doesn't leak into the next command's footer rendering. @@ -12405,8 +12425,9 @@ impl TerminalView { ); } - // We don't want any suggestion UIs on AI requested blocks. - if !block_completed.was_part_of_agent_interaction { + // We don't want any suggestion UIs on AI requested blocks, nor on a resume + // Warp wrote itself. + if block_completed.was_user_authored() { self.maybe_generate_command_suggestions(block_completed, ctx); if self.can_suggest_alias_expansion(ctx) { @@ -15798,8 +15819,9 @@ impl TerminalView { return; } - // Don't send notifications for commands executed by an agent - if block.was_part_of_agent_interaction { + // Don't send notifications for commands executed by an agent, or for a resume Warp wrote + // itself: a notification is Warp telling the user their command finished. + if !block.was_user_authored() { return; } @@ -15953,8 +15975,54 @@ impl TerminalView { } } + /// Arms the invocation that reattaches this restored pane to its previous agent session. It + /// runs once the shell is ready for a command, on the same gate a launch-config command uses. + pub(crate) fn arm_agent_session_resume(&mut self, command: String) { + self.pending_agent_session_resume = Some(command); + } + + /// The resume invocation still armed for this pane, if any. + #[cfg(test)] + pub(crate) fn armed_agent_session_resume(&self) -> Option<&str> { + self.pending_agent_session_resume.as_deref() + } + + /// Runs an armed resume invocation, if the pane is still one Warp may write into. + /// + /// Taken rather than peeked: a pane that was not writable at the ready gate is a pane the user + /// has already claimed, and a deferred injection would land in the middle of their work. + fn run_pending_agent_session_resume(&mut self, ctx: &mut ViewContext) { + let Some(command) = self.pending_agent_session_resume.take() else { + return; + }; + + // Anything already in the buffer — a draft the user typed, a launch-config command waiting + // on the same gate — makes this the user's pane. Warp yields rather than merging the two + // into one line or racing them into the shell. + let is_occupied = self.input.read(ctx, |input, ctx| { + input.has_pending_command() || !input.buffer_text(ctx).is_empty() + }); + if is_occupied { + return; + } + + // Decided here rather than where the invocation is built, because which mechanism keeps a + // line out of history is a property of the pane's shell and nothing upstream knows it. + let command = + history_suppressed_resume_command(self.active_session_shell_type(ctx), command); + self.input.update(ctx, |input, ctx| { + input.execute_agent_session_resume(&command, ctx); + }); + } + + /// Drops an armed resume because the user has started using the pane. + fn cancel_agent_session_resume(&mut self) { + self.pending_agent_session_resume = None; + } + /// Executes a command that was submitted by the user and not yet sent to the shell. pub fn execute_pending_command(&mut self, _: (), ctx: &mut ViewContext) { + self.run_pending_agent_session_resume(ctx); let had_pending = self.input.read(ctx, |input, _| input.has_pending_command()); self.input.update(ctx, |input, ctx| { input.execute_pending_command(ctx); @@ -21597,7 +21665,9 @@ impl TerminalView { ctx.emit(Event::ExecuteCommand(event.as_ref().clone())); - if self.block_onboarding_active { + // R24: onboarding is interrupted by the user starting to work, which a resume + // Warp wrote itself is not. + if self.block_onboarding_active && !event.source.is_warp_authored() { self.interrupt_onboarding_blocks(ctx); } } @@ -21838,6 +21908,12 @@ impl TerminalView { } InputEvent::InputStateChanged(_) => {} InputEvent::InputEmptyStateChanged { is_empty, reason } => { + // The user typing into a restored pane makes it theirs, even if they clear the + // buffer again afterwards. The armed resume is dropped, not deferred. + if !*is_empty && matches!(reason, InputEmptyStateChangeReason::Edited) { + self.cancel_agent_session_resume(); + } + // Update the universal developer input button bar with the new empty state let universal_developer_input_button_bar = self .input diff --git a/app/src/terminal/view/zero_state_block.rs b/app/src/terminal/view/zero_state_block.rs index fa9c7ec1fed..c1be8f13e82 100644 --- a/app/src/terminal/view/zero_state_block.rs +++ b/app/src/terminal/view/zero_state_block.rs @@ -66,8 +66,13 @@ impl TerminalViewZeroStateBlock { ctx.subscribe_to_model( model_events_dispatcher, move |me, model_events_dispatcher, event, ctx| { + // R24: the affordance is spent by the user's first block. A resume Warp wrote + // itself is Warp filling the pane in, not the user starting to work in it. if let ModelEvent::BlockCompleted(block_completed) = event - && matches!(block_completed.block_type, BlockType::User(..)) + && matches!( + &block_completed.block_type, + BlockType::User(user_block) if user_block.was_user_authored() + ) { me.should_hide = true; ctx.unsubscribe_to_model(&model_events_dispatcher); @@ -409,3 +414,7 @@ mod styles { pub const TITLE_MARGIN_BOTTOM: f32 = 8.; } + +#[cfg(test)] +#[path = "zero_state_block_tests.rs"] +mod tests; diff --git a/app/src/terminal/view/zero_state_block_tests.rs b/app/src/terminal/view/zero_state_block_tests.rs new file mode 100644 index 00000000000..3dc0ddf5ad1 --- /dev/null +++ b/app/src/terminal/view/zero_state_block_tests.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use warpui::App; + +use super::*; +use crate::terminal::event::{BlockCompletedEvent, UserBlockCompleted}; +use crate::terminal::model::block::{BlockId, SerializedBlock}; +use crate::terminal::model::terminal_model::BlockIndex; +use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view}; + +fn completed_block(was_warp_authored: bool) -> BlockType { + BlockType::User(UserBlockCompleted { + index: BlockIndex::zero(), + serialized_block: Arc::new(SerializedBlock::new_for_test(b"claude".to_vec(), vec![])), + command: "claude".to_owned(), + command_with_obfuscated_secrets: "claude".to_owned(), + output_truncated: String::new(), + output_truncated_with_obfuscated_secrets: String::new(), + was_part_of_agent_interaction: false, + was_warp_authored, + started_at: None, + num_output_lines: 0, + num_output_lines_truncated: 0, + }) +} + +/// AE14/R24: the pane's zero-state affordance is spent by the user's first block. A resume is +/// Warp filling the pane in, not the user starting to work in it. +#[test] +fn zero_state_affordance_survives_a_resume_block() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + let (zero_state, dispatcher) = terminal.update(&mut app, |view, ctx| { + let controller = view.agent_view_controller.clone(); + let dispatcher = view.model_event_dispatcher().clone(); + let zero_state = + ctx.add_view(|ctx| TerminalViewZeroStateBlock::new(&controller, &dispatcher, ctx)); + (zero_state, dispatcher) + }); + + let emit = |app: &mut App, block_type: BlockType| { + dispatcher.update(app, |_, ctx| { + ctx.emit(ModelEvent::BlockCompleted(BlockCompletedEvent { + block_type, + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id: BlockId::new(), + session_id: None, + restored_block_was_local: None, + })); + }); + }; + + emit(&mut app, completed_block(/*was_warp_authored=*/ true)); + zero_state.read(&app, |zero_state, _| { + assert!( + !zero_state.should_hide, + "a resume must not spend the pane's zero-state affordance" + ); + }); + + emit(&mut app, completed_block(/*was_warp_authored=*/ false)); + zero_state.read(&app, |zero_state, _| { + assert!( + zero_state.should_hide, + "the user's own first block still hides it" + ); + }); + }); +} diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 50a44575b6f..6924f3e2699 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -68,6 +68,7 @@ use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, }; +use crate::terminal::event::BlockCompletedEvent; use crate::terminal::model::ansi::{self, BootstrappedValue, InitShellValue, PreexecValue}; use crate::terminal::model::block::AgentViewVisibility; use crate::terminal::model::blocks::{TotalIndex, insert_block}; @@ -6213,7 +6214,7 @@ fn completed_user_controlled_lrc_resumes_when_not_suppressed() { .has_active_stream_for_conversation(conversation_id, ctx) ); - view.on_user_block_completed(&block_id, ctx); + view.on_user_block_completed(&block_id, /*was_user_authored=*/ true, ctx); // A Ctrl-C takeover (Stop) without an explicit teardown should resume the // conversation once the command completes, just like a manual takeover. @@ -6262,7 +6263,7 @@ fn completed_user_controlled_lrc_skips_resume_when_suppressed() { active_block.id().clone() }; - view.on_user_block_completed(&block_id, ctx); + view.on_user_block_completed(&block_id, /*was_user_authored=*/ true, ctx); assert!( !view @@ -8967,6 +8968,188 @@ fn warp_tui_listener_does_not_auto_open_rich_input() { }); }); } + +/// Delivers an OSC 777 CLI agent notification the way a live PTY would, so both +/// the terminal view and any registered listener observe it. +fn emit_cli_agent_notification( + body: &str, + view: &TerminalView, + ctx: &mut ViewContext, +) { + let dispatcher = view.model_event_dispatcher().clone(); + let body = body.to_owned(); + dispatcher.update(ctx, |_, ctx| { + ctx.emit(ModelEvent::PluggableNotification { + title: Some(CLI_AGENT_NOTIFICATION_SENTINEL.to_owned()), + body, + }); + }); +} + +fn recorded_cli_agent_session_id(view: &TerminalView, ctx: &AppContext) -> Option { + CLIAgentSessionsModel::as_ref(ctx) + .session(view.view_id) + .expect("CLI agent session should exist") + .session_context + .session_id + .clone() +} + +/// A pane whose agent starts a second conversation must record the newest +/// session id, not the one captured when the listener was registered. +#[test] +fn cli_agent_second_session_start_replaces_recorded_session_id() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + terminal.update(&mut app, |view, ctx| { + emit_cli_agent_notification( + r#"{"v":1,"agent":"claude","event":"session_start","session_id":"conversation-a"}"#, + view, + ctx, + ); + }); + + terminal.read(&app, |view, ctx| { + assert_eq!( + recorded_cli_agent_session_id(view, ctx).as_deref(), + Some("conversation-a") + ); + }); + + terminal.update(&mut app, |view, ctx| { + emit_cli_agent_notification( + r#"{"v":1,"agent":"claude","event":"session_start","session_id":"conversation-b"}"#, + view, + ctx, + ); + }); + + terminal.read(&app, |view, ctx| { + assert_eq!( + recorded_cli_agent_session_id(view, ctx).as_deref(), + Some("conversation-b") + ); + }); + }); +} + +/// Delivers a block-completed event the way a foreground process ending or +/// being suspended into the background would. +fn emit_block_completed( + block_type: BlockType, + view: &TerminalView, + ctx: &mut ViewContext, +) { + let dispatcher = view.model_event_dispatcher().clone(); + dispatcher.update(ctx, |_, ctx| { + ctx.emit(ModelEvent::BlockCompleted(BlockCompletedEvent { + block_type, + num_secrets_obfuscated: 0, + block_index: BlockIndex::zero(), + block_id: BlockId::new(), + session_id: None, + restored_block_was_local: None, + })); + }); +} + +fn completed_user_block(command: &str) -> BlockType { + BlockType::User(UserBlockCompleted { + index: BlockIndex::zero(), + serialized_block: Arc::new(SerializedBlock::new_for_test( + command.as_bytes().to_vec(), + vec![], + )), + command: command.to_owned(), + command_with_obfuscated_secrets: command.to_owned(), + output_truncated: String::new(), + output_truncated_with_obfuscated_secrets: String::new(), + was_part_of_agent_interaction: false, + was_warp_authored: false, + started_at: None, + num_output_lines: 0, + num_output_lines_truncated: 0, + }) +} + +/// The agent exits and the user launches it again in the same pane; the pane +/// must record the conversation of the second run. +#[test] +fn cli_agent_relaunched_in_pane_records_new_session_id() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + terminal.update(&mut app, |view, ctx| { + emit_cli_agent_notification( + r#"{"v":1,"agent":"claude","event":"session_start","session_id":"conversation-a"}"#, + view, + ctx, + ); + emit_block_completed(completed_user_block("claude"), view, ctx); + }); + + terminal.read(&app, |view, ctx| { + assert!( + CLIAgentSessionsModel::as_ref(ctx) + .session(view.view_id) + .is_none(), + "the exiting agent should end its session" + ); + }); + + terminal.update(&mut app, |view, ctx| { + emit_cli_agent_notification( + r#"{"v":1,"agent":"claude","event":"session_start","session_id":"conversation-b"}"#, + view, + ctx, + ); + }); + + terminal.read(&app, |view, ctx| { + assert_eq!( + recorded_cli_agent_session_id(view, ctx).as_deref(), + Some("conversation-b") + ); + }); + }); +} + +/// Suspending the agent completes a background block rather than the user +/// block, so the session and its recorded conversation survive. +#[test] +fn cli_agent_suspended_into_background_block_keeps_session_id() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + terminal.update(&mut app, |view, ctx| { + emit_cli_agent_notification( + r#"{"v":1,"agent":"claude","event":"session_start","session_id":"conversation-a"}"#, + view, + ctx, + ); + emit_block_completed( + BlockType::Background(Arc::new(SerializedBlock::new_for_test( + b"claude".to_vec(), + vec![], + ))), + view, + ctx, + ); + }); + + terminal.read(&app, |view, ctx| { + assert_eq!( + recorded_cli_agent_session_id(view, ctx).as_deref(), + Some("conversation-a") + ); + }); + }); +} + #[test] fn active_cli_agent_recognizes_detected_warp_tui_session() { App::test((), |mut app| async move { @@ -9183,3 +9366,194 @@ fn back_button_label_resolves_token_only_parent_linkage() { }); }); } + +/// The same completed block, but authored by Warp rather than by the person at the keyboard. +fn completed_resume_block(command: &str) -> BlockType { + let BlockType::User(mut block) = completed_user_block(command) else { + unreachable!("completed_user_block builds a user block") + }; + block.was_warp_authored = true; + BlockType::User(block) +} + +/// AE14/R24: the AI-analytics consent banner is spent by the user's first completed block, and a +/// resume is not that block. The contrast run proves the dismissal still happens for a real one. +#[test] +fn resume_block_does_not_dismiss_the_ai_analytics_banner() { + App::test((), |mut app| async move { + let _banner_flag = FeatureFlag::GlobalAIAnalyticsBanner.override_enabled(true); + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + terminal.update(&mut app, |view, ctx| { + emit_block_completed( + completed_resume_block("claude --resume 'session-1'"), + view, + ctx, + ); + }); + terminal.read(&app, |_, ctx| { + assert!( + !GeneralSettings::as_ref(ctx) + .telemetry_banner_dismissed + .value(), + "a resume must not spend the user's one-time consent banner" + ); + }); + + terminal.update(&mut app, |view, ctx| { + emit_block_completed(completed_user_block("ls"), view, ctx); + }); + terminal.read(&app, |_, ctx| { + assert!( + GeneralSettings::as_ref(ctx) + .telemetry_banner_dismissed + .value(), + "the user's own first block still dismisses it" + ); + }); + }); +} + +/// R24: onboarding is interrupted by the user running something, so it must survive a line the +/// user never ran. +#[test] +fn resume_execution_does_not_interrupt_onboarding() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + let input = terminal.read(&app, |view, _| view.input().clone()); + let session_id = terminal + .read(&app, |view, _| { + view.model.lock().block_list().active_block().session_id() + }) + .unwrap_or_else(|| 0.into()); + + let emit_execute = |app: &mut App, source: CommandExecutionSource| { + input.update(app, move |_, ctx| { + ctx.emit(crate::terminal::input::Event::ExecuteCommand(Box::new( + ExecuteCommandEvent { + command: "claude --resume 'session-1'".to_owned(), + session_id, + workflow_id: None, + workflow_command: None, + should_add_command_to_history: false, + source, + }, + ))); + }); + }; + + terminal.update(&mut app, |view, _| { + view.block_onboarding_active = true; + }); + emit_execute(&mut app, CommandExecutionSource::AgentSessionResume); + terminal.read(&app, |view, _| { + assert!( + view.block_onboarding_active, + "a resume must not interrupt the onboarding blocks" + ); + }); + + emit_execute(&mut app, CommandExecutionSource::User); + terminal.read(&app, |view, _| { + assert!( + !view.block_onboarding_active, + "the user running something still interrupts onboarding" + ); + }); + }); +} + +/// The input-buffer clear is one of the consumers that keys off a completed user block. A resume +/// completing must leave a draft the user is composing exactly where it was. +#[test] +fn resume_block_completing_leaves_a_draft_alone() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + let input = terminal.read(&app, |view, _| view.input().clone()); + + input.update(&mut app, |input, ctx| { + input.replace_buffer_content("half-typed follow-up", ctx); + }); + // The clear only runs once per block, so each case needs a block of its own to complete. + terminal.update(&mut app, |view, ctx| { + view.model.lock().simulate_block("claude", ""); + emit_block_completed( + completed_resume_block("claude --resume 'session-1'"), + view, + ctx, + ); + }); + assert_eq!( + input.read(&app, |input, ctx| input.buffer_text(ctx)), + "half-typed follow-up", + "a resume completing must not reinitialize the user's buffer" + ); + + terminal.update(&mut app, |view, ctx| { + view.model.lock().simulate_block("ls", ""); + emit_block_completed(completed_user_block("ls"), view, ctx); + }); + assert_eq!( + input.read(&app, |input, ctx| input.buffer_text(ctx)), + "", + "a user command completing still clears the buffer" + ); + }); +} + +/// R24: the agent context a user has staged is reset by their own completed block, on the reading +/// that they have moved on to something else. A resume is Warp filling the pane in, so whatever +/// they had selected for their next query is still what they selected. +#[test] +fn resume_block_leaves_the_staged_agent_context_alone() { + App::test((), |mut app| async move { + let _block_context = FeatureFlag::AgentViewBlockContext.override_enabled(false); + initialize_app_for_terminal_view(&mut app); + + let terminal = add_window_with_terminal(&mut app, None); + let context_model = terminal.read(&app, |view, _| view.ai_context_model().clone()); + let stage_context = |app: &mut App| { + context_model.update(app, |context, ctx| { + context.set_pending_context_selected_text( + Some("staged selection".to_owned()), + true, + ctx, + ); + }); + }; + + stage_context(&mut app); + terminal.update(&mut app, |view, ctx| { + view.model.lock().simulate_block("claude", ""); + emit_block_completed( + completed_resume_block("claude --resume 'session-1'"), + view, + ctx, + ); + }); + context_model.read(&app, |context, _| { + assert_eq!( + context.pending_context_selected_text().map(String::as_str), + Some("staged selection"), + "a resume must not discard the context the user staged for their next query" + ); + }); + + terminal.update(&mut app, |view, ctx| { + view.model.lock().simulate_block("ls", ""); + emit_block_completed(completed_user_block("ls"), view, ctx); + }); + context_model.read(&app, |context, _| { + assert_eq!( + context.pending_context_selected_text(), + None, + "the user's own block still resets the staged context" + ); + }); + }); +} diff --git a/app/src/terminal/writeable_pty/pty_controller.rs b/app/src/terminal/writeable_pty/pty_controller.rs index 2ead78492cd..85ab9d5eba3 100644 --- a/app/src/terminal/writeable_pty/pty_controller.rs +++ b/app/src/terminal/writeable_pty/pty_controller.rs @@ -543,6 +543,9 @@ impl PtyController { CommandExecutionSource::User | CommandExecutionSource::QueuedCommand => { model.start_command_execution() } + CommandExecutionSource::AgentSessionResume => { + model.start_command_execution_as_warp_authored() + } CommandExecutionSource::EnvVarCollection { metadata } => { model.start_command_execution_from_env_var_collection(metadata) } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index c487a1a6b12..832d53a6f66 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -226,9 +226,9 @@ use crate::ai_assistant::execution_context::WarpAiExecutionContext; use crate::ai_assistant::panel::{AIAssistantPanelEvent, AIAssistantPanelView}; use crate::ai_assistant::{AI_ASSISTANT_FEATURE_NAME, AI_ASSISTANT_LOGO_COLOR, AskAIType}; use crate::app_state::{ - LeafContents, LeafSnapshot, LeftPanelDisplayedTab, LeftPanelSnapshot, NotebookPaneSnapshot, - PaneNodeSnapshot, PaneUuid, RightPanelSnapshot, SettingsPaneSnapshot, TabGroupSnapshot, - TabSnapshot, TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot, + AgentSessionRestore, LeafContents, LeafSnapshot, LeftPanelDisplayedTab, LeftPanelSnapshot, + NotebookPaneSnapshot, PaneNodeSnapshot, PaneUuid, RightPanelSnapshot, SettingsPaneSnapshot, + TabGroupSnapshot, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot, }; use crate::appearance::{Appearance, AppearanceManager}; use crate::auth::AuthStateProvider; @@ -3881,6 +3881,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::Template(tab_template.layout_with_tab_commands()), Arc::new(HashMap::new()), + AgentSessionRestore::default(), tab_template.title.clone(), ctx, ); @@ -3919,6 +3920,7 @@ impl Workspace { NewWorkspaceSource::Restored { window_snapshot, block_lists, + agent_restore, } => { let active_tab_index = window_snapshot.active_tab_index; let restored_left_panel_open = window_snapshot.left_panel_open; @@ -3957,6 +3959,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::Snapshot(Box::new(saved_tab.root.clone())), block_lists.clone(), + agent_restore.clone(), custom_title, ctx, ); @@ -4018,6 +4021,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::SingleTerminal(options), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -4037,6 +4041,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::SingleTerminal(options), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -4050,6 +4055,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::AmbientAgent, Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -4085,6 +4091,7 @@ impl Workspace { self.add_tab_with_pane_layout( Default::default(), Arc::new(HashMap::new()), + AgentSessionRestore::default(), custom_title, ctx, ); @@ -4114,6 +4121,7 @@ impl Workspace { self.add_tab_with_pane_layout( Default::default(), Arc::new(HashMap::new()), + AgentSessionRestore::default(), custom_title, ctx, ); @@ -6415,6 +6423,7 @@ impl Workspace { ..Default::default() })), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -7110,6 +7119,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::Template(pane_template), Arc::new(HashMap::new()), + AgentSessionRestore::default(), rendered_title, ctx, ); @@ -8638,6 +8648,7 @@ impl Workspace { self.add_tab_with_pane_layout( panes_layout, Arc::new(HashMap::new()), + AgentSessionRestore::default(), Some("Settings".to_owned()), ctx, ); @@ -8735,6 +8746,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::SingleTerminal(Box::new(options)), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -12541,6 +12553,7 @@ impl Workspace { contents: LeafContents::GetStarted, }))), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -12607,6 +12620,7 @@ impl Workspace { self.add_tab_with_pane_layout( PanesLayout::AmbientAgent, Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -12712,6 +12726,7 @@ impl Workspace { ..Default::default() })), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, /*custom_tab_title*/ ctx, ); @@ -12796,6 +12811,7 @@ impl Workspace { &mut self, panes_layout: PanesLayout, block_lists: Arc>>, + agent_restore: AgentSessionRestore, custom_tab_title: Option, ctx: &mut ViewContext, ) { @@ -12822,6 +12838,7 @@ impl Workspace { self.server_api.clone(), panes_layout, block_lists, + agent_restore, self.model_event_sender.clone(), ctx, ); @@ -12949,7 +12966,13 @@ impl Workspace { settings: settings.clone(), }), }))); - self.add_tab_with_pane_layout(panes_layout, Arc::new(HashMap::new()), None, ctx); + self.add_tab_with_pane_layout( + panes_layout, + Arc::new(HashMap::new()), + AgentSessionRestore::default(), + None, + ctx, + ); } fn add_tab_for_cloud_workflow( @@ -12966,7 +12989,13 @@ impl Workspace { settings: settings.clone(), }), }))); - self.add_tab_with_pane_layout(panes_layout, Arc::new(HashMap::new()), None, ctx); + self.add_tab_with_pane_layout( + panes_layout, + Arc::new(HashMap::new()), + AgentSessionRestore::default(), + None, + ctx, + ); } /// Add a tab with a file notebook pane open. @@ -12982,7 +13011,13 @@ impl Workspace { path: file_path, }), }))); - self.add_tab_with_pane_layout(panes_layout, Arc::new(HashMap::new()), None, ctx); + self.add_tab_with_pane_layout( + panes_layout, + Arc::new(HashMap::new()), + AgentSessionRestore::default(), + None, + ctx, + ); } pub fn add_tab_for_assisted_autoupdate( @@ -12994,6 +13029,7 @@ impl Workspace { self.add_tab_with_pane_layout( Default::default(), Arc::new(HashMap::new()), + AgentSessionRestore::default(), Some("Install Update".to_owned()), ctx, ); @@ -13166,6 +13202,7 @@ impl Workspace { ..Default::default() })), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -23701,6 +23738,7 @@ impl Workspace { ..Default::default() })), Arc::new(HashMap::new()), + AgentSessionRestore::default(), Some("Introducing Oz".to_string()), ctx, ); diff --git a/app/src/workspace/view/onboarding.rs b/app/src/workspace/view/onboarding.rs index 8cd32ab596f..2adb2482f6b 100644 --- a/app/src/workspace/view/onboarding.rs +++ b/app/src/workspace/view/onboarding.rs @@ -7,6 +7,7 @@ use warp_core::execution_mode::AppExecutionMode; use warp_errors::report_error; use warpui::{SingletonEntity as _, ViewContext}; +use crate::app_state::AgentSessionRestore; use crate::pane_group::{NewTerminalOptions, PanesLayout}; use crate::settings::AISettings; use crate::terminal::view::{ @@ -137,6 +138,7 @@ impl Workspace { ..Default::default() })), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 7a0ff65bf58..d10448d3fa4 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -334,6 +334,7 @@ fn restored_workspace( NewWorkspaceSource::Restored { window_snapshot, block_lists: Arc::new(HashMap::new()), + agent_restore: AgentSessionRestore::default(), }, ctx, ) @@ -3103,6 +3104,7 @@ fn add_get_started_tab(workspace: &mut Workspace, ctx: &mut ViewContext>::new()), + AgentSessionRestore::default(), None, ctx, ); diff --git a/crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/down.sql b/crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/down.sql new file mode 100644 index 00000000000..b0f712b098c --- /dev/null +++ b/crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/down.sql @@ -0,0 +1 @@ +DROP TABLE agent_sessions; diff --git a/crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/up.sql b/crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/up.sql new file mode 100644 index 00000000000..aa907cfe046 --- /dev/null +++ b/crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/up.sql @@ -0,0 +1,18 @@ +-- Agent session state lives outside the pane snapshot tables on purpose: those are deleted and +-- rebuilt wholesale by every session save, including the save that restore itself triggers, so a +-- row stored there would be erased before it could be read back. +CREATE TABLE agent_sessions ( + id INTEGER PRIMARY KEY NOT NULL, + -- No foreign key to pane_leaves for the same reason blocks has none: pane rows are recreated + -- by every snapshot, which would leave these rows violating the constraint. + pane_leaf_uuid BLOB NOT NULL, + -- Nullable because the writer degrades an unserializable value to NULL rather than failing; + -- a NOT NULL column would turn that into a constraint error. + agent_kind TEXT, + session_id TEXT NOT NULL, + flags TEXT, + directory BLOB NOT NULL, + observed_at TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX ux_agent_sessions_pane_leaf_uuid ON agent_sessions (pane_leaf_uuid); diff --git a/crates/persistence/src/model.rs b/crates/persistence/src/model.rs index 31a42727cda..8ace49c0313 100644 --- a/crates/persistence/src/model.rs +++ b/crates/persistence/src/model.rs @@ -9,10 +9,10 @@ use warp_multi_agent_api::response_event::stream_finished; use warp_multi_agent_api::{self as api}; use super::schema::{ - active_mcp_servers, agent_conversations, agent_tasks, ai_document_panes, ai_memory_panes, - ambient_agent_panes, app, blocks, cloud_objects_refreshes, code_pane_tabs, code_panes, - code_review_panes, commands, current_user_information, env_var_collection_panes, folders, - generic_string_objects, ignored_suggestions, mcp_environment_variables, + active_mcp_servers, agent_conversations, agent_sessions, agent_tasks, ai_document_panes, + ai_memory_panes, ambient_agent_panes, app, blocks, cloud_objects_refreshes, code_pane_tabs, + code_panes, code_review_panes, commands, current_user_information, env_var_collection_panes, + folders, generic_string_objects, ignored_suggestions, mcp_environment_variables, mcp_server_installations, mcp_server_panes, notebook_panes, notebooks, object_actions, object_metadata, object_permissions, pane_branches, pane_leaves, pane_nodes, panels, project_rules, projects, server_experiments, settings_panes, tab_groups, tabs, team_members, @@ -773,6 +773,37 @@ pub struct Block { pub agent_view_visibility: Option, } +/// Agent CLI state recorded for a pane so that a restart can offer to resume it. +/// +/// `agent_kind` and `flags` are nullable because the writer degrades a value it cannot serialize +/// to `NULL` instead of failing: an insert that errored would surface as a write failure for +/// state the user never asked to persist. +#[derive(Insertable, AsChangeset)] +#[diesel(table_name = agent_sessions)] +#[diesel(treat_none_as_null = true)] +pub struct NewAgentSession { + // No pane leaf UUID foreign key, for the same reason `NewBlock` has none: pane rows are + // recreated by every snapshot, so the constraint would be violated as soon as a pane closes. + pub pane_leaf_uuid: Vec, + pub agent_kind: Option, + pub session_id: String, + pub flags: Option, + pub directory: Vec, + pub observed_at: NaiveDateTime, +} + +#[derive(Identifiable, Queryable, Selectable)] +#[diesel(table_name = agent_sessions)] +pub struct AgentSession { + pub id: i32, + pub pane_leaf_uuid: Vec, + pub agent_kind: Option, + pub session_id: String, + pub flags: Option, + pub directory: Vec, + pub observed_at: NaiveDateTime, +} + #[derive(Insertable)] #[diesel(table_name = commands)] pub struct NewCommand { diff --git a/crates/persistence/src/schema.rs b/crates/persistence/src/schema.rs index 38b4e8ebcdd..67df1502b8e 100644 --- a/crates/persistence/src/schema.rs +++ b/crates/persistence/src/schema.rs @@ -17,6 +17,18 @@ diesel::table! { } } +diesel::table! { + agent_sessions (id) { + id -> Integer, + pane_leaf_uuid -> Binary, + agent_kind -> Nullable, + session_id -> Text, + flags -> Nullable, + directory -> Binary, + observed_at -> Timestamp, + } +} + diesel::table! { agent_tasks (id) { id -> Integer, diff --git a/crates/warp_features/src/features_tests.rs b/crates/warp_features/src/features_tests.rs index 5f570baa89a..43f19b70afa 100644 --- a/crates/warp_features/src/features_tests.rs +++ b/crates/warp_features/src/features_tests.rs @@ -18,3 +18,12 @@ fn local_child_harnesses_are_local_only_by_default() { assert!(!DEBUG_FLAGS.contains(&FeatureFlag::LocalClaudeCodexChildHarnesses)); assert!(!DOGFOOD_FLAGS.contains(&FeatureFlag::LocalClaudeCodexChildHarnesses)); } + +#[test] +fn agent_session_resume_is_local_only_and_runtime_toggleable() { + assert!(LOCAL_FLAGS.contains(&FeatureFlag::AgentSessionResume)); + assert!(RUNTIME_FEATURE_FLAGS.contains(&FeatureFlag::AgentSessionResume)); + assert!(!DEBUG_FLAGS.contains(&FeatureFlag::AgentSessionResume)); + assert!(!DOGFOOD_FLAGS.contains(&FeatureFlag::AgentSessionResume)); + assert!(!PREVIEW_FLAGS.contains(&FeatureFlag::AgentSessionResume)); +} diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 7f6d9c7fed8..6ff7adf06cc 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -706,6 +706,11 @@ pub enum FeatureFlag { /// flows while the default behavior temporarily keeps them disabled. LocalClaudeCodexChildHarnesses, + /// Restores a pane that had a CLI agent in the foreground onto that agent's + /// previous conversation, by running the agent's own resume invocation + /// instead of leaving a bare shell. + AgentSessionResume, + /// On `wait_for_events`, confirms parent status against the server and /// registers an orchestrator for the owner-side ancestor stream so it /// receives events for children created out-of-band (Oz CLI / web API). @@ -974,7 +979,10 @@ static FEATURES_INITIALIZED: AtomicBool = AtomicBool::new(false); /// Features used in debugging. pub const DEBUG_FLAGS: &[FeatureFlag] = &[FeatureFlag::DebugMode, FeatureFlag::RuntimeFeatureFlags]; /// Features enabled only for the WarpLocal developer build. -pub const LOCAL_FLAGS: &[FeatureFlag] = &[FeatureFlag::LocalClaudeCodexChildHarnesses]; +pub const LOCAL_FLAGS: &[FeatureFlag] = &[ + FeatureFlag::LocalClaudeCodexChildHarnesses, + FeatureFlag::AgentSessionResume, +]; /// Features enabled for the development team. The expectation is that, over /// time, these will move on to PREVIEW_FLAGS before being launched. @@ -1056,7 +1064,10 @@ pub const RELEASE_FLAGS: &[FeatureFlag] = &[ ]; /// Flags that we want to allow to switch at runtime (assuming RuntimeFeatureFlags is set) -pub const RUNTIME_FEATURE_FLAGS: &[FeatureFlag] = &[FeatureFlag::LocalClaudeCodexChildHarnesses]; +pub const RUNTIME_FEATURE_FLAGS: &[FeatureFlag] = &[ + FeatureFlag::LocalClaudeCodexChildHarnesses, + FeatureFlag::AgentSessionResume, +]; impl FeatureFlag { pub fn is_enabled(&self) -> bool {