From 1292ddacdf3779ad4d44556cba897412c2c187c3 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 14:32:31 +0200 Subject: [PATCH 01/12] feat(terminal): keep restored agent-session invocations out of shell history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing per-shell suppression that already excludes Warp's in-band generator commands from the real history file so it also drops the invocation Warp runs when restoring a pane onto an agent's previous session. The three shells share one marker, `warp_resume_agent_session`, appended to the invocation as a trailing comment: comment syntax is identical in zsh, bash, and PowerShell, so the marker is inert to the shell, and it contains no `:` that would break bash's HISTIGNORE separator. The leading-space route is deliberately not used — Warp unsets hist_ignore_space and HISTCONTROL after bootstrap so the user's own history options govern. This is inert until a resume actually runs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/assets/bundled/bootstrap/bash_body.sh | 6 ++++-- app/assets/bundled/bootstrap/pwsh.ps1 | 5 +++++ app/assets/bundled/bootstrap/zsh_body.sh | 17 ++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) 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, From 6104e104da92d5f1b632667963d814ca6df828c8 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 14:36:05 +0200 Subject: [PATCH 02/12] feat(features): add the AgentSessionResume feature flag Adds the cargo feature, the FeatureFlag variant, and the compile-time to runtime bridge, registered in LOCAL_FLAGS and RUNTIME_FEATURE_FLAGS so the whole agent-session-resume path is switchable at runtime and off by default. Deliberately not added to the crate's `default` feature list. Follows the FeatureFlag::LocalClaudeCodexChildHarnesses registration shape across all five sites. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/Cargo.toml | 1 + app/src/features.rs | 2 ++ crates/warp_features/src/features_tests.rs | 9 +++++++++ crates/warp_features/src/lib.rs | 15 +++++++++++++-- 4 files changed, 25 insertions(+), 2 deletions(-) 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/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/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 { From ecc04bab659d0a150ba3b255344f931d64b0838a Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 15:05:01 +0200 Subject: [PATCH 03/12] fix(cli-agent): follow a new conversation started in the same pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A post-registration `session_start` was discarded outright, so a pane whose user started a second conversation kept reporting the first conversation's identifier. The CLI-agent footer already misattributes status because of it. `CLIAgentSessionHandler::handle_event` now receives the identifier the session currently holds, and `DefaultSessionListener` drops a `session_start` only when it carries no identifier or repeats the recorded one. An event reporting a genuinely new identifier reaches `update_from_event`, whose existing latch adopts it without ever overwriting a known identifier with `None`. Only the listener needed changing. The view's early return does not block the event: the listener holds its own subscription to the same dispatcher and receives every subsequent notification. Removing that early return would process each post-registration event twice — duplicate `SessionUpdated`, duplicate `CLIAgentPluginDetected` telemetry, and a re-fired rich-input auto-open. `register_listener`'s existing-session path already adopts a newly reported identifier and is not on this path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- .../cli_agent_sessions/listener/mod.rs | 44 ++++- .../cli_agent_sessions/listener/mod_tests.rs | 69 ++++++- .../terminal/cli_agent_sessions/mod_tests.rs | 77 ++++++++ app/src/terminal/view_tests.rs | 182 ++++++++++++++++++ 4 files changed, 352 insertions(+), 20 deletions(-) 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_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/view_tests.rs b/app/src/terminal/view_tests.rs index 50a44575b6f..526d8dda4d7 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}; @@ -8967,6 +8968,187 @@ 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, + 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 { From fec031358a0ad0726888b672e831282f151f105b Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 15:43:57 +0200 Subject: [PATCH 04/12] feat(persistence): record agent session state in its own pane-keyed table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `agent_sessions` table keyed by pane uuid, holding the agent kind, the identifier the agent itself reported, the resume-relevant flags, the directory, and when the state was observed. The table follows the `blocks` precedent and is deliberately absent from `save_app_state`'s delete list. A row on a snapshot-rebuilt table such as `terminal_panes` would be erased by every full save — including the save that restore itself triggers per pane at shell bootstrap — so the value could never survive to be read back. Two tests pin that guarantee, and both were shown to fail when the table was added to the delete list. The directory is recorded here rather than read back from the pane snapshot so eligibility can later compare it against the directory the pane actually restored into, without two columns claiming the same fact. It is stored as a BLOB via the existing path encoding so non-UTF-8 paths survive. `agent_kind` and `flags` are nullable on purpose: the writer degrades an unserializable value to NULL rather than failing, and NOT NULL would turn that degradation into a constraint error that aborts the whole snapshot transaction. The read side treats either being absent or unparseable as "no recording" rather than resuming from a half-known invocation. `AgentSessionRestore` carries the loaded map plus an explicit startup-pass flag, because `restore_pane_leaf` is also reachable from `add_tab_with_pane_layout`; only `open_from_restored` sets it. Nothing emits the write event yet and the restore call site only logs — capture, eligibility, and launching are separate units. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/src/app_state.rs | 41 +++- app/src/app_state_tests.rs | 41 ++++ app/src/launch_configs/launch_config_tests.rs | 2 + app/src/pane_group/mod.rs | 19 +- app/src/pane_group/mod_tests.rs | 80 +++++++ app/src/persistence/mod.rs | 9 +- app/src/persistence/sqlite.rs | 76 +++++- app/src/persistence/sqlite_tests.rs | 216 +++++++++++++++++- app/src/root_view.rs | 15 +- app/src/workspace/view.rs | 50 +++- app/src/workspace/view/onboarding.rs | 2 + app/src/workspace/view_tests.rs | 2 + .../down.sql | 1 + .../up.sql | 18 ++ crates/persistence/src/model.rs | 39 +++- crates/persistence/src/schema.rs | 12 + 16 files changed, 600 insertions(+), 23 deletions(-) create mode 100644 crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/down.sql create mode 100644 crates/persistence/migrations/2026-08-11-120000_create_agent_sessions/up.sql diff --git a/app/src/app_state.rs b/app/src/app_state.rs index d288cc8c770..e4ec4226cc1 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; 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,7 @@ 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::{CLIAgent, ShellLaunchData}; use crate::themes::theme::AnsiColorIdentifier; use crate::workspace::WorkspaceRegistry; use crate::workspace::tab_group::TabGroupId; @@ -29,12 +30,49 @@ 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, + /// Flags from the invocation the user ran that matter when relaunching the agent. + 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>, + /// 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 { + /// The state recorded for `pane_uuid`, and only on the startup restore pass. + pub fn recorded_on_startup(&self, pane_uuid: &PaneUuid) -> Option<&RecordedAgentSession> { + self.is_startup_restore + .then(|| self.sessions.get(pane_uuid)) + .flatten() + } +} + /// Wrapper for persisting agent management filters to restore. #[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PersistedAgentManagementFilters { @@ -392,6 +430,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/app_state_tests.rs b/app/src/app_state_tests.rs index d5eaf3063f4..baedfa25d1d 100644 --- a/app/src/app_state_tests.rs +++ b/app/src/app_state_tests.rs @@ -106,3 +106,44 @@ fn test_code_pane_snapshot_with_multiple_tabs() { assert_eq!(tabs[2].path, None); assert!(matches!(source, Some(CodeSource::Link { .. }))); } + +fn recorded_session() -> RecordedAgentSession { + RecordedAgentSession { + agent: CLIAgent::Claude, + session_id: "session-1".to_owned(), + flags: vec!["--resume".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"), + } +} + +fn startup_restore(pane_uuid: Vec) -> AgentSessionRestore { + AgentSessionRestore { + sessions: Arc::new(HashMap::from([(PaneUuid(pane_uuid), recorded_session())])), + is_startup_restore: true, + } +} + +#[test] +fn recorded_session_is_found_by_the_uuid_the_pane_reports() { + let restore = startup_restore(vec![4, 2]); + + assert_eq!( + restore.recorded_on_startup(&PaneUuid(vec![4, 2])), + Some(&recorded_session()) + ); + assert_eq!(restore.recorded_on_startup(&PaneUuid(vec![4, 3])), None); +} + +// Adding a tab from a snapshot mid-session walks the same restore path as startup, and resuming +// an agent there would relaunch something the user never had running in that tab. +#[test] +fn recorded_session_is_withheld_when_the_restore_is_not_the_startup_pass() { + let mut restore = startup_restore(vec![4, 2]); + restore.is_startup_restore = false; + + assert_eq!(restore.recorded_on_startup(&PaneUuid(vec![4, 2])), None); +} 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..451f1c710d5 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -68,9 +68,9 @@ 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, + SettingsPaneSnapshot, TerminalPaneSnapshot, WorkflowPaneSnapshot, }; use crate::appearance::Appearance; use crate::auth::AuthStateProvider; @@ -1497,6 +1497,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 +1511,7 @@ impl PaneGroup { PaneNodeSnapshot::Leaf(leaf) => Self::restore_pane_leaf( leaf, block_lists, + agent_restore, resources, ctx, pane_contents, @@ -1541,6 +1543,7 @@ impl PaneGroup { match PaneGroup::restore_pane_tree( node, block_lists.clone(), + agent_restore.clone(), resources.clone(), ctx, pane_contents, @@ -1577,6 +1580,7 @@ impl PaneGroup { fn restore_pane_leaf( leaf: LeafSnapshot, block_lists: Arc>>, + agent_restore: AgentSessionRestore, resources: TerminalViewResources, ctx: &mut ViewContext, pane_contents: &mut HashMap>, @@ -1614,6 +1618,13 @@ impl PaneGroup { let uuid = PaneUuid(terminal_snapshot.uuid.clone()); let block_list = block_lists.get(&uuid); + if let Some(recorded_agent) = agent_restore.recorded_on_startup(&uuid) { + log::info!( + "Restoring pane with a recorded {:?} agent session", + recorded_agent.agent + ); + } + let chosen_shell = terminal_snapshot .shell_launch_data .as_ref() @@ -3390,6 +3401,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 +3436,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..c017008fc4e 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -253,6 +253,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 +3204,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 +3525,81 @@ 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 = crate::app_state::RecordedAgentSession { + agent: crate::terminal::CLIAgent::Claude, + session_id: "session-1".to_owned(), + flags: vec!["--model".to_owned(), "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(), + )])), + 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.recorded_on_startup(&PaneUuid(reported_uuid)), + Some(&recorded) + ); + }); +} diff --git a/app/src/persistence/mod.rs b/app/src/persistence/mod.rs index 896d4164eec..5b2f163a572 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,13 @@ pub struct FinishedCommandMetadata { pub enum ModelEvent { SaveBlock(BlockCompleted), DeleteBlocks(Vec), + /// Records the agent CLI a pane is running. Deliberately not folded into + /// [`ModelEvent::Snapshot`]: snapshots rebuild the pane tables wholesale, so this state needs + /// a write of its own to survive them. + SaveAgentSession { + pane_id: Vec, + session: RecordedAgentSession, + }, Snapshot(AppState), UpsertWorkflows(Vec), UpsertNotebooks(Vec), diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index ab9616788a9..af52affd813 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,9 @@ 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::SaveAgentSession { pane_id, session } => { + save_agent_session(connection, pane_id, &session).context("error saving agent session") + } ModelEvent::Snapshot(app_state) => { save_app_state(connection, &app_state).context("error saving app state") } @@ -1511,6 +1514,71 @@ 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(()) +} + +/// 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 +2774,7 @@ fn read_sqlite_data( .collect(); let restored_blocks = get_all_restored_blocks(conn)?; + let recorded_agent_sessions = get_all_recorded_agent_sessions(conn)?; // Load active MCP servers from database let running_mcp_servers = load_active_mcp_servers(conn)?; @@ -2714,6 +2783,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..6abe24a15d0 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,9 @@ use crate::persistence::{ }; use crate::server::ids::{ClientId, ServerId}; use crate::tab::SelectedTabColor; -use crate::terminal::ShellLaunchData; 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 +152,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 +311,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(), }; @@ -432,6 +439,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 +474,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 +543,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 +623,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 +749,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 +911,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 +1045,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 +1085,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 +1108,196 @@ 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]; + +fn test_recorded_agent_session() -> RecordedAgentSession { + RecordedAgentSession { + agent: CLIAgent::Claude, + session_id: "b7c2f1a0-5f3e-4c21-9b8d-0f2a1c3d4e5f".to_owned(), + flags: vec!["--model".to_owned(), "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) + ); +} + +#[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()); +} + +#[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..312d5b8201a 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::{ @@ -797,6 +797,12 @@ 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. + let agent_restore = AgentSessionRestore { + sessions: app_state.agent_sessions.clone(), + 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 +840,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 +881,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 +934,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 +1627,7 @@ pub enum NewWorkspaceSource { Restored { window_snapshot: WindowSnapshot, block_lists: Arc>>, + agent_restore: AgentSessionRestore, }, Session { options: Box, @@ -3130,6 +3140,7 @@ impl RootView { .with_initial_directory_opt(path_if_directory(path).map(Into::into)), )), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -3305,6 +3316,7 @@ impl RootView { workspace.add_tab_with_pane_layout( PanesLayout::SingleTerminal(Box::default()), Arc::new(HashMap::new()), + AgentSessionRestore::default(), None, ctx, ); @@ -3350,6 +3362,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/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, From cb0b69efb30d3a7536d786c08bf51d555b5640de Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 16:18:48 +0200 Subject: [PATCH 05/12] feat(terminal): declare per-agent resume support as embedded configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an embedded TOML declaration of how each agent CLI reattaches to a prior session, plus the builder, validator, and capture-side flag extractor that read it. Adding an agent is a file edit; there is no per-agent restore path. The declaration never names an executable — the binary comes from the detected agent's `command_prefixes()` — and a key that tries to name one is a load error. Two invocation shapes are modelled because both exist in the wild: a flag taking the identifier (`claude --resume `) and a subcommand taking it (`codex resume `). Every recorded value is treated as untrusted: the store is a local database file any process running as the user can write, and the built string is parsed by an interactive shell. Two independent barriers keep it safe. Each declared shape is a character allowlist rather than a metacharacter denylist, so whitespace, `;`, `$`, backticks, globs, newlines, quotes and non-ASCII lookalikes are all rejected by construction; a failing value drops its flag rather than being repaired. Every surviving value is then unconditionally single-quoted, and quoting refuses a value containing a single quote — the one character that could end the wrapping. A resume pointer that fails its shape yields no command at all rather than a partial line. The builder adds no flag of its own, unlike the headless driver builders which attach approval bypasses unconditionally. Flags are emitted before the identifier so an agent taking a trailing prompt positional cannot swallow them. Only Claude and Codex are declared. Gemini's resume is unverified against a released CLI, and WarpTui reports a process-local entity id over OSC rather than the token its `--resume` accepts, so a resume built from what we record could never reattach. Claude's variadic `--add-dir` and `--mcp-config` are excluded because a variadic flag is indistinguishable from a trailing prompt positional once tokenized. The built invocation carries the `warp_resume_agent_session` trailing-comment marker the shell bootstrap files already suppress from history. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/resources/cli_agent_resume/agents.toml | 67 +++ app/src/terminal/cli_agent_resume.rs | 473 ++++++++++++++++++++ app/src/terminal/cli_agent_resume_tests.rs | 492 +++++++++++++++++++++ app/src/terminal/mod.rs | 1 + 4 files changed, 1033 insertions(+) create mode 100644 app/resources/cli_agent_resume/agents.toml create mode 100644 app/src/terminal/cli_agent_resume.rs create mode 100644 app/src/terminal/cli_agent_resume_tests.rs diff --git a/app/resources/cli_agent_resume/agents.toml b/app/resources/cli_agent_resume/agents.toml new file mode 100644 index 00000000000..4281e887de6 --- /dev/null +++ b/app/resources/cli_agent_resume/agents.toml @@ -0,0 +1,67 @@ +# 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 `/`. +# +# 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`. +# +# 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 } +# 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" } +"--strict-mcp-config" = { shape = "boolean" } +"--agent" = { shape = "bare_token", max_length = 64 } +"--settings" = { shape = "path_like", max_length = 512 } + +[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" } diff --git a/app/src/terminal/cli_agent_resume.rs b/app/src/terminal/cli_agent_resume.rs new file mode 100644 index 00000000000..8330b24f01b --- /dev/null +++ b/app/src/terminal/cli_agent_resume.rs @@ -0,0 +1,473 @@ +//! 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 serde::{Deserialize, Serialize}; +use warp_errors::report_error; + +use crate::terminal::CLIAgent; + +/// 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 the bootstrap scripts in `app/assets/bundled/bootstrap/` +/// (`zsh_body.sh`, `bash_body.sh`, `pwsh.ps1`); changing this string means changing +/// all three. `#` starts a comment in all three shells, so the marker stays inert. +pub const RESUME_HISTORY_MARKER: &str = "warp_resume_agent_session"; + +const EMBEDDED_DECLARATIONS: &str = include_str!("../../resources/cli_agent_resume/agents.toml"); + +/// 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, +} + +#[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, +} + +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; + if !arg.starts_with('-') { + continue; + } + + 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 shell command that reattaches `agent` to `identifier`, carrying whichever of + /// `flags` still validate. + /// + /// 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. + pub fn build_resume_command( + &self, + agent: CLIAgent, + identifier: &str, + flags: &[RecordedFlag], + ) -> 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; + }; + 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")); + } + 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, + }) + } +} + +/// 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) +} + +/// 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..990c89dd674 --- /dev/null +++ b/app/src/terminal/cli_agent_resume_tests.rs @@ -0,0 +1,492 @@ +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) + .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, &[]), + 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, &[]), + 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, &[]) + .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 Date: Tue, 11 Aug 2026 17:05:14 +0200 Subject: [PATCH 06/12] feat(pane-group): decide agent-resume eligibility before restore builds anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two-stage gate that decides, silently, which restored panes may reattach to a recorded agent session. Duplicate identifiers are resolved first, as a pure function over the loaded pane-uuid map plus the window layout, before any window is created. Each window is its own `add_window` call and the active one is created last, so a first-wins-over-restore-order rule would hand a shared identifier to a background pane. Claims rank by landing window, then visible window, active tab, focused leaf, newest observation, and finally pane-uuid bytes as a stable tie-break. The per-leaf gate then returns a typed reason rather than a bool, so each rejection is separately reportable. Rejection is always silent: no marker, badge, message, or toast, because explaining a skipped resume would put a Warp-specific artifact into a pane the user expects to look like their own terminal. Remoteness and shared-session viewing cannot be read from `CLIAgentSession` — that is a live runtime struct which does not exist during startup restore, and the recorded row carries no such column. The only restore-time evidence is the snapshot itself: a cwd reaches it solely via `pwd_if_local`, and `input_config` is left unset only by the viewer branches of `TerminalPane::snapshot`. Each check names its source. Because the save path also drops the cwd for a local pane whose directory vanished before the last save, that variant stands for both unresumable cases; both restore as a shell, which is the acceptance criterion. Directory comparison canonicalizes both sides, since raw `PathBuf` equality reports a false mismatch for symlinked paths such as macOS `/tmp` against `/private/tmp`. The claim check runs last so a pane that could not have resumed anyway does not take the identifier from one that could. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/src/app_state.rs | 10 +- app/src/app_state_tests.rs | 6 +- app/src/pane_group/mod.rs | 222 ++++++++++++- app/src/pane_group/mod_tests.rs | 536 ++++++++++++++++++++++++++++++++ app/src/root_view.rs | 11 +- 5 files changed, 773 insertions(+), 12 deletions(-) diff --git a/app/src/app_state.rs b/app/src/app_state.rs index e4ec4226cc1..bee1f29fd88 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; @@ -58,6 +58,9 @@ pub struct RecordedAgentSession { #[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. @@ -71,6 +74,11 @@ impl AgentSessionRestore { .then(|| self.sessions.get(pane_uuid)) .flatten() } + + /// 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. diff --git a/app/src/app_state_tests.rs b/app/src/app_state_tests.rs index baedfa25d1d..b775a135a66 100644 --- a/app/src/app_state_tests.rs +++ b/app/src/app_state_tests.rs @@ -122,7 +122,11 @@ fn recorded_session() -> RecordedAgentSession { fn startup_restore(pane_uuid: Vec) -> AgentSessionRestore { AgentSessionRestore { - sessions: Arc::new(HashMap::from([(PaneUuid(pane_uuid), recorded_session())])), + sessions: Arc::new(HashMap::from([( + PaneUuid(pane_uuid.clone()), + recorded_session(), + )])), + claimed_panes: Arc::new(HashSet::from([PaneUuid(pane_uuid)])), is_startup_restore: true, } } diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 451f1c710d5..feb34dd28ed 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; use itertools::Itertools; use lazy_static::lazy_static; use markdown_parser::FormattedTextFragment; @@ -70,7 +72,8 @@ use crate::app_state::CodePaneSnapShot; use crate::app_state::{ self, AIFactPaneSnapshot, AgentSessionRestore, BranchSnapshot, EnvVarCollectionPaneSnapshot, LeafContents, LeafSnapshot, NotebookPaneSnapshot, PaneNodeSnapshot, PaneUuid, - SettingsPaneSnapshot, TerminalPaneSnapshot, WorkflowPaneSnapshot, + 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::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; @@ -1090,6 +1094,191 @@ 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; + } + + 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() +} + +/// 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. +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 = resolved_directory(&recorded.directory) + .ok_or(ResumeIneligibility::RecordedDirectoryMissing)?; + if restored_directory.and_then(resolved_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) +} + +/// `path` as it resolves on disk right now, or `None` when nothing is there. Both sides of a +/// directory comparison 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 resolved_directory(path: &Path) -> Option { + path.is_dir() + .then(|| dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AIDocumentPaneVisibilityAction { /// Ensure the requested AI document pane is visible. @@ -1618,13 +1807,6 @@ impl PaneGroup { let uuid = PaneUuid(terminal_snapshot.uuid.clone()); let block_list = block_lists.get(&uuid); - if let Some(recorded_agent) = agent_restore.recorded_on_startup(&uuid) { - log::info!( - "Restoring pane with a recorded {:?} agent session", - recorded_agent.agent - ); - } - let chosen_shell = terminal_snapshot .shell_launch_data .as_ref() @@ -1638,9 +1820,31 @@ 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; a later unit turns an eligible one into the resume invocation. + let resume_verdict = resume_eligibility( + &agent_restore, + &uuid, + &terminal_snapshot, + startup_directory.as_deref(), + ); + match &resume_verdict { + Ok(recorded) => log::info!( + "Restored pane can resume its recorded {:?} agent session", + recorded.agent + ), + // The ordinary outcome for every pane that was not running an agent, so + // reporting it would say nothing about this feature. + Err(ResumeIneligibility::NoRecordedSession) => {} + Err(reason) => { + log::info!("Restored pane will not resume an agent session: {reason:?}") + } + } + // 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 diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index c017008fc4e..97ef694d785 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -3549,6 +3549,7 @@ fn restored_terminal_pane_reports_the_uuid_its_recorded_session_is_keyed_by() { PaneUuid(pane_uuid.clone()), recorded.clone(), )])), + claimed_panes: Arc::new(HashSet::from([PaneUuid(pane_uuid.clone())])), is_startup_restore: true, }; @@ -3603,3 +3604,538 @@ fn restored_terminal_pane_reports_the_uuid_its_recorded_session_is_keyed_by() { ); }); } + +/// 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: crate::terminal::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![crate::app_state::TabSnapshot { + custom_title: None, + root: PaneNodeSnapshot::Branch(BranchSnapshot { + direction: crate::app_state::SplitDirection::Horizontal, + children: panes + .into_iter() + .map(|pane| { + ( + crate::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: 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 = crate::terminal::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 = crate::terminal::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); + }); +} diff --git a/app/src/root_view.rs b/app/src/root_view.rs index 312d5b8201a..ddefa1b0274 100644 --- a/app/src/root_view.rs +++ b/app/src/root_view.rs @@ -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; @@ -799,8 +799,17 @@ fn open_from_restored(arg: &OpenFromRestoredArg, ctx: &mut AppContext) { 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() { From f8671115f1af112d9f5b50bd1cc815d65c278162 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 18:24:37 +0200 Subject: [PATCH 07/12] feat(terminal): record a pane's agent session as the agent reports it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the capture side: a per-pane subscriber, filtered on the terminal view id it captured at subscription time, records the agent kind, the newest identifier the agent reported, the allowlisted flags of the user's own invocation, the directory, and when it was observed. `SaveAgentSession` becomes `SetAgentSession { session: Option<..> }` so a save and a clear share one order and one coalescing key. Only allowlisted, alias-resolved flags are recorded — never the raw command line, and never the session context. The capture path reads the sessions model solely through a new accessor returning the agent and identifier, so there is no reachable path to the user's prompts, the agent's replies, a summary, a tool preview, or a draft. A test sets every one of those fields to a marker, destructures the recorded struct exhaustively so a new field cannot be added without review, and asserts none of it is persisted. Command text is read only through the secrets-obfuscated accessor; obfuscated cells render as `*`, which the declared value shapes reject at build time. Ordering follows one total order: the send happens on the main thread from the subscription callback rather than from independent spawned tasks, which have no ordering guarantee and could land a newer identifier before an older one. Volume is cut twice — unchanged observations are dropped at the source, and the writer coalesces per pane uuid — because the session event fires once per tool call, not once per turn, against a channel a blocking main-thread sender also uses. Coalescing never hoists a snapshot past a capture write. State is cleared only when the agent ends while its pane is still attached. `detach` clears the attached flag before `remove_session` runs, so hidden-for- close, closed, and teardown detaches are invisible to capture — which is what keeps an undone close resumable. A suspended agent completes a background block rather than a user block, so it never ends its session and keeps its state. `resolve_command_aliases` is factored out of `CLIAgent::detect` unchanged, because flag capture needs the alias-expanded text and `detect` returns only the agent. Panes are not unsubscribed on detach: subscriptions are keyed by the subscribing PaneGroup view, so unsubscribing one pane would kill its siblings' capture. `RecordedAgentSession.flags` becomes `Vec` to match what the extractor produces and the resume builder consumes, avoiding a lossy re-parse. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/src/app_state.rs | 7 +- app/src/app_state_tests.rs | 5 +- app/src/pane_group/mod_tests.rs | 521 +++++++++++++++++- app/src/pane_group/pane/terminal_pane.rs | 260 ++++++++- .../pane_group/pane/terminal_pane_tests.rs | 115 +++- app/src/persistence/mod.rs | 14 +- app/src/persistence/sqlite.rs | 67 ++- app/src/persistence/sqlite_tests.rs | 215 +++++++- app/src/terminal/cli_agent.rs | 48 +- app/src/terminal/cli_agent_sessions/mod.rs | 12 + 10 files changed, 1215 insertions(+), 49 deletions(-) diff --git a/app/src/app_state.rs b/app/src/app_state.rs index bee1f29fd88..e86fe73a20f 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -19,6 +19,7 @@ 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::cli_agent_resume::RecordedFlag; use crate::terminal::{CLIAgent, ShellLaunchData}; use crate::themes::theme::AnsiColorIdentifier; use crate::workspace::WorkspaceRegistry; @@ -45,8 +46,10 @@ pub struct RecordedAgentSession { pub agent: CLIAgent, /// The session identifier the agent itself reported. pub session_id: String, - /// Flags from the invocation the user ran that matter when relaunching the agent. - pub flags: Vec, + /// 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. diff --git a/app/src/app_state_tests.rs b/app/src/app_state_tests.rs index b775a135a66..4bc863eb567 100644 --- a/app/src/app_state_tests.rs +++ b/app/src/app_state_tests.rs @@ -111,7 +111,10 @@ fn recorded_session() -> RecordedAgentSession { RecordedAgentSession { agent: CLIAgent::Claude, session_id: "session-1".to_owned(), - flags: vec!["--resume".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") diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 97ef694d785..ea169e8991d 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; @@ -85,12 +86,21 @@ use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::system::SystemStats; use crate::terminal::alt_screen_reporting::AltScreenReporting; -use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +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, @@ -3537,7 +3547,10 @@ fn restored_terminal_pane_reports_the_uuid_its_recorded_session_is_keyed_by() { let recorded = crate::app_state::RecordedAgentSession { agent: crate::terminal::CLIAgent::Claude, session_id: "session-1".to_owned(), - flags: vec!["--model".to_owned(), "opus".to_owned()], + flags: vec![crate::terminal::cli_agent_resume::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") @@ -3605,6 +3618,510 @@ fn restored_terminal_pane_reports_the_uuid_its_recorded_session_is_keyed_by() { }); } +/// 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: crate::terminal::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, + 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") + }) +} + +// 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 { + 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, crate::terminal::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(), crate::terminal::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 { + 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 { + 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 { + 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. + 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" + ); + + // Teardown detaches every pane before the writer is drained. + pane_group.update(&mut app, |panes, ctx| panes.clean_up_panes(ctx)); + assert_eq!( + captured_agent_session_writes(&model_events), + vec![], + "app teardown must not clear what its panes 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 { + 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 { + 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, crate::terminal::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 { + 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 { + 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" + ); + }); +} + +// 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 { + 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: crate::terminal::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 { diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index bfe2187a1f1..e7d38e53acc 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1,15 +1,21 @@ //! 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_errors::report_error; +use warp_util::path::EscapeChar; use warpui::{ AppContext, EntityId, ModelHandle, SingletonEntity, ViewContext, ViewHandle, WindowId, }; @@ -38,7 +44,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 +59,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 +68,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 +83,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 +112,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 +191,7 @@ impl TerminalPane { model_event_sender, uuid, pane_configuration, + agent_capture: Arc::new(AgentCaptureState::default()), view, } } @@ -247,6 +280,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 +309,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 +419,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. @@ -612,6 +668,202 @@ 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; + } + + // 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 !*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 { .. } => { + match observed_agent_session(group, terminal_pane_id, event.terminal_view_id(), ctx) { + // Nothing to record until the agent has reported an identifier: a recording + // without one claims no session and resumes nothing. + None => return, + session => session, + } + } + 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 + } + _ => 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 state to record for `terminal_pane_id`, or `None` while its agent has reported no +/// session identifier. +fn observed_agent_session( + group: &PaneGroup, + terminal_pane_id: TerminalPaneId, + terminal_view_id: EntityId, + ctx: &AppContext, +) -> Option { + 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()))?; + + 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()), + ); + + Some(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(); + } + + // Splitting on whitespace splits a quoted value too, but every shape the allowlist declares + // is a bare token, so a value that needed quoting was never one a resume could carry. + let args = resolved.split_whitespace().skip(1).collect::>(); + ResumeDeclarations::embedded().extract_resume_flags(agent, &args) +} + 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..d85ca8686d0 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,117 @@ 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" + ); +} + +// 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 --settings ********", + Some(EscapeChar::Backslash), + None, + ); + + assert_eq!(recorded, vec![flag("--settings", Some("********"))]); + assert!( + ResumeDeclarations::embedded() + .build_resume_command(CLIAgent::Claude, "session-1", &recorded) + .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/persistence/mod.rs b/app/src/persistence/mod.rs index 5b2f163a572..ce4e60fb03f 100644 --- a/app/src/persistence/mod.rs +++ b/app/src/persistence/mod.rs @@ -346,12 +346,16 @@ pub struct FinishedCommandMetadata { pub enum ModelEvent { SaveBlock(BlockCompleted), DeleteBlocks(Vec), - /// Records the agent CLI a pane is running. Deliberately not folded into - /// [`ModelEvent::Snapshot`]: snapshots rebuild the pane tables wholesale, so this state needs - /// a write of its own to survive them. - SaveAgentSession { + /// 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. + SetAgentSession { pane_id: Vec, - session: RecordedAgentSession, + session: Option, }, Snapshot(AppState), UpsertWorkflows(Vec), diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index af52affd813..5b305480b8a 100644 --- a/app/src/persistence/sqlite.rs +++ b/app/src/persistence/sqlite.rs @@ -644,9 +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::SaveAgentSession { pane_id, session } => { - save_agent_session(connection, pane_id, &session).context("error saving agent session") - } + 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") } @@ -891,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. @@ -1545,6 +1569,15 @@ fn save_agent_session( 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(()) +} + /// 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 diff --git a/app/src/persistence/sqlite_tests.rs b/app/src/persistence/sqlite_tests.rs index 6abe24a15d0..ab7f907c59a 100644 --- a/app/src/persistence/sqlite_tests.rs +++ b/app/src/persistence/sqlite_tests.rs @@ -33,6 +33,7 @@ use crate::persistence::{ }; use crate::server::ids::{ClientId, ServerId}; use crate::tab::SelectedTabColor; +use crate::terminal::cli_agent_resume::RecordedFlag; use crate::terminal::model::block::SerializedBlock; use crate::terminal::model::session::SessionId; use crate::terminal::{CLIAgent, ShellLaunchData}; @@ -366,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 { @@ -1111,11 +1208,33 @@ fn test_sqlite_drops_too_small_bounds_on_read() { 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!["--model".to_owned(), "opus".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") @@ -1160,6 +1279,100 @@ fn agent_session_round_trips_through_save_and_load() { ); } +// 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"); 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_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 From 84500335752697e326e62fd10bdc8e274213f550 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 19:48:29 +0200 Subject: [PATCH 08/12] feat(terminal): run a restored pane's resume invocation as Warp's own command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An eligible restored pane now executes its agent's resume invocation instead of coming up as a bare shell, without touching the user's input buffer, their history, or the one-time state Warp reserves for a user's first command. Three layers, because a command-source variant alone reaches almost nothing. A new `AgentSessionResume` source carries `should_add_command_to_history: false`, which gates both Warp's history and the persisted commands table, and suppresses the arm that reports executed command text as telemetry. A block-level `is_warp_authored` marker then covers everything that reads `was_part_of_agent_interaction` — that value is derived from `ai_metadata` and is structurally false for a resume, so a source variant cannot reach it. `was_user_authored()` becomes the single choke point every consumer reads. The shell's own history file is already handled by the bootstrap marker. Three consumer classes needed the marker, not two. Passive suggestions have a second live implementation beyond the one the plan listed, which would otherwise have produced suggestions from a resume. The pane's zero-state affordance matches on the block type with a wildcard rather than on agent metadata, so it is reachable by neither the source nor the metadata; it needed the block marker specifically, and gained the sibling test file it never had. The consent-banner site receives only a block id, so authorship is threaded to it explicitly. The invocation is held as data and dispatched from the existing shell-bootstrap gate rather than staged in the user's editor: `set_pending_command` inserts without clearing and then executes the whole buffer. The arm is taken before execution, so a failure is left exactly as a terminal leaves any failed command — no retry. A pane holding user text, a pane the user has typed into, and a pane with a queued launch-config command all refuse the injection. Permission-posture flags are carried only when the recording was observed within a bounded window, now including the valued `--permission-mode`, not just boolean bypass switches. A recording dated in the future is treated as stale, so winding the clock back cannot revive an expired elevation. Past the window the pane still resumes, just without the posture. The 12-hour value is provisional and doc-commented as a rollout decision; a newly declared posture flag fails the suite until it is acknowledged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/resources/cli_agent_resume/agents.toml | 13 +- app/src/ai/blocklist/context_model.rs | 4 +- .../blocklist/passive_suggestions/legacy.rs | 2 +- .../ai/blocklist/passive_suggestions/maa.rs | 2 +- app/src/ai/predict/next_command_model.rs | 10 + app/src/pane_group/mod.rs | 48 ++- app/src/pane_group/mod_tests.rs | 180 ++++++++++ .../pane_group/pane/terminal_pane_tests.rs | 7 +- app/src/terminal/cli_agent_resume.rs | 71 +++- app/src/terminal/cli_agent_resume_tests.rs | 178 +++++++++- app/src/terminal/event.rs | 16 + app/src/terminal/input.rs | 79 ++++- app/src/terminal/input_tests.rs | 308 +++++++++++++++++- app/src/terminal/model/block.rs | 18 + app/src/terminal/model/terminal_model.rs | 10 + .../terminal/model/terminal_model_tests.rs | 46 +++ app/src/terminal/view.rs | 91 +++++- app/src/terminal/view/zero_state_block.rs | 11 +- .../terminal/view/zero_state_block_tests.rs | 72 ++++ app/src/terminal/view_tests.rs | 144 +++++++- .../terminal/writeable_pty/pty_controller.rs | 3 + 21 files changed, 1253 insertions(+), 60 deletions(-) create mode 100644 app/src/terminal/view/zero_state_block_tests.rs diff --git a/app/resources/cli_agent_resume/agents.toml b/app/resources/cli_agent_resume/agents.toml index 4281e887de6..58691d16477 100644 --- a/app/resources/cli_agent_resume/agents.toml +++ b/app/resources/cli_agent_resume/agents.toml @@ -25,6 +25,13 @@ # 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. @@ -48,10 +55,10 @@ identifier = { shape = "bare_token", max_length = 128 } # 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-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" } +"--dangerously-skip-permissions" = { shape = "boolean", permission_posture = true } "--strict-mcp-config" = { shape = "boolean" } "--agent" = { shape = "bare_token", max_length = 64 } "--settings" = { shape = "path_like", max_length = 512 } @@ -64,4 +71,4 @@ resume = { form = "subcommand", subcommand = "resume" } identifier = { shape = "bare_token", max_length = 128 } [agents.Codex.flags] -"--dangerously-bypass-approvals-and-sandbox" = { shape = "boolean" } +"--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/pane_group/mod.rs b/app/src/pane_group/mod.rs index feb34dd28ed..2acff90814a 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -8,7 +8,7 @@ use std::rc::Rc; use std::sync::Arc; use std::sync::mpsc::SyncSender; -use chrono::NaiveDateTime; +use chrono::{NaiveDateTime, Utc}; use itertools::Itertools; use lazy_static::lazy_static; use markdown_parser::FormattedTextFragment; @@ -121,7 +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::ResumeDeclarations; +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; @@ -1271,6 +1271,21 @@ pub(crate) fn resume_eligibility<'a>( 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` as it resolves on disk right now, or `None` when nothing is there. Both sides of a /// directory comparison go through this so that two spellings of one directory — a symlinked /// temporary directory, `/tmp` against `/private/tmp` — are not read as two directories. @@ -1825,25 +1840,30 @@ impl PaneGroup { .filter(|path| path.is_dir()); // The verdict is decided here, where the directory the pane is about to come up - // in is known; a later unit turns an eligible one into the resume invocation. + // in is known. let resume_verdict = resume_eligibility( &agent_restore, &uuid, &terminal_snapshot, startup_directory.as_deref(), ); - match &resume_verdict { - Ok(recorded) => log::info!( - "Restored pane can resume its recorded {:?} agent session", - recorded.agent - ), + let resume_command = match &resume_verdict { + Ok(_) if !FeatureFlag::AgentSessionResume.is_enabled() => None, + 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. - Err(ResumeIneligibility::NoRecordedSession) => {} + Err(ResumeIneligibility::NoRecordedSession) => None, Err(reason) => { - log::info!("Restored pane will not resume an agent session: {reason:?}") + log::info!("Restored pane will not resume an agent session: {reason:?}"); + None } - } + }; // Filter conversation IDs to only include those that have task messages // and are not entirely passive (ignored suggestions). @@ -1902,6 +1922,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, diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index ea169e8991d..b8eb183a7f6 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -86,6 +86,7 @@ use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::system::SystemStats; use crate::terminal::alt_screen_reporting::AltScreenReporting; +use crate::terminal::cli_agent_resume::RESUME_HISTORY_MARKER; use crate::terminal::cli_agent_sessions::event::parse_event; use crate::terminal::cli_agent_sessions::{ CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, @@ -3757,6 +3758,7 @@ fn completed_user_block(command: &str) -> BlockType { 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, @@ -4656,3 +4658,181 @@ fn an_ineligible_recorded_session_restores_the_pane_unchanged() { 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| { + ( + crate::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: crate::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()); + }); +} diff --git a/app/src/pane_group/pane/terminal_pane_tests.rs b/app/src/pane_group/pane/terminal_pane_tests.rs index d85ca8686d0..42775ee690c 100644 --- a/app/src/pane_group/pane/terminal_pane_tests.rs +++ b/app/src/pane_group/pane/terminal_pane_tests.rs @@ -102,7 +102,12 @@ fn recorded_flags_carry_the_obfuscated_placeholder_rather_than_a_secret() { assert_eq!(recorded, vec![flag("--settings", Some("********"))]); assert!( ResumeDeclarations::embedded() - .build_resume_command(CLIAgent::Claude, "session-1", &recorded) + .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" ); diff --git a/app/src/terminal/cli_agent_resume.rs b/app/src/terminal/cli_agent_resume.rs index 8330b24f01b..797a1943c13 100644 --- a/app/src/terminal/cli_agent_resume.rs +++ b/app/src/terminal/cli_agent_resume.rs @@ -14,7 +14,9 @@ use std::collections::HashMap; use std::sync::LazyLock; +use std::time::Duration; +use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; use warp_errors::report_error; @@ -30,6 +32,40 @@ 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] = &['.', '_', '-', '+', ':', '@']; @@ -120,6 +156,10 @@ struct RawValue { 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)] @@ -150,6 +190,7 @@ enum ResumeInvocation { struct ValueDeclaration { shape: ValueShape, max_length: usize, + permission_posture: bool, } impl ValueDeclaration { @@ -284,15 +325,30 @@ impl ResumeDeclarations { } /// The shell command that reattaches `agent` to `identifier`, carrying whichever of - /// `flags` still validate. + /// `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. + /// its declared shape: without a usable pointer there is no invocation to salvage. A + /// [`PermissionPosture::Drop`] never costs the resume, only the elevation. + /// The allowlisted flags `agent` declares as choosing a permission posture. + 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() + } + 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) { @@ -314,6 +370,11 @@ impl ResumeDeclarations { 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(' '); @@ -360,6 +421,11 @@ impl AgentDeclaration { 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( @@ -448,6 +514,7 @@ impl ValueDeclaration { Ok(ValueDeclaration { shape: raw.shape, max_length, + permission_posture: raw.permission_posture, }) } } diff --git a/app/src/terminal/cli_agent_resume_tests.rs b/app/src/terminal/cli_agent_resume_tests.rs index 990c89dd674..b6055cc60ac 100644 --- a/app/src/terminal/cli_agent_resume_tests.rs +++ b/app/src/terminal/cli_agent_resume_tests.rs @@ -1,3 +1,6 @@ +use std::time::Duration; + +use chrono::{NaiveDate, NaiveDateTime, TimeDelta}; use enum_iterator::all; use super::*; @@ -17,7 +20,12 @@ fn flag(name: &str, value: Option<&str>) -> RecordedFlag { fn claude_command(flags: &[RecordedFlag]) -> String { declarations() - .build_resume_command(CLIAgent::Claude, SESSION_ID, flags) + .build_resume_command( + CLIAgent::Claude, + SESSION_ID, + flags, + PermissionPosture::Carry, + ) .expect("Claude is declared and the identifier is well formed") } @@ -56,7 +64,7 @@ fn agents_without_a_verified_resume_are_undeclared() { "{agent:?} must stay undeclared" ); assert_eq!( - declarations().build_resume_command(agent, SESSION_ID, &[]), + declarations().build_resume_command(agent, SESSION_ID, &[], PermissionPosture::Carry), None, "{agent:?} must not build any invocation" ); @@ -180,7 +188,12 @@ fn flag_form_agent_builds_a_resume_flag_invocation() { #[test] fn subcommand_form_agent_builds_a_resume_subcommand_invocation() { assert_eq!( - declarations().build_resume_command(CLIAgent::Codex, SESSION_ID, &[]), + declarations().build_resume_command( + CLIAgent::Codex, + SESSION_ID, + &[], + PermissionPosture::Carry + ), Some(format!( "codex resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}" )) @@ -216,7 +229,7 @@ fn recorded_permission_bypass_flag_is_carried_verbatim() { fn builder_adds_no_flag_of_its_own() { let command = claude_command(&[]); let codex_command = declarations() - .build_resume_command(CLIAgent::Codex, SESSION_ID, &[]) + .build_resume_command(CLIAgent::Codex, SESSION_ID, &[], PermissionPosture::Carry) .expect("Codex is declared"); for unwanted in [ @@ -309,7 +322,12 @@ fn unusable_identifier_yields_no_command() { for identifier in unusable { assert_eq!( - declarations().build_resume_command(CLIAgent::Claude, identifier, &[]), + declarations().build_resume_command( + CLIAgent::Claude, + identifier, + &[], + PermissionPosture::Carry + ), None, "identifier {identifier:?} must yield no command at all" ); @@ -317,7 +335,8 @@ fn unusable_identifier_yields_no_command() { declarations().build_resume_command( CLIAgent::Claude, identifier, - &[flag("--dangerously-skip-permissions", None)] + &[flag("--dangerously-skip-permissions", None)], + PermissionPosture::Carry ), None, "identifier {identifier:?} must yield no command even with valid flags" @@ -473,7 +492,8 @@ identifier = { shape = "bare_token", max_length = 128 } declarations.build_resume_command( CLIAgent::Claude, SESSION_ID, - &[flag("--permissionMode", Some("plan"))] + &[flag("--permissionMode", Some("plan"))], + PermissionPosture::Carry ), Some(format!( "claude --permission-mode 'plan' --resume '{SESSION_ID}' # {RESUME_HISTORY_MARKER}" @@ -490,3 +510,147 @@ fn extractor_yields_nothing_for_an_undeclared_agent() { .is_empty() ); } + +/// The recording timestamps the window is measured against, expressed as an age. +fn observed_hours_ago(hours: i64) -> (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/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..2883870bfe6 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( @@ -7559,6 +7589,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 +7659,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 +7709,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 +15187,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 +15433,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/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..cfed5d6a0e1 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -2577,6 +2577,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 +4328,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 +11139,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 +11192,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 +11919,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 +12424,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 +15818,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 +15974,50 @@ 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; + } + + 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 +21660,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 +21903,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 526d8dda4d7..945d94bb7ca 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -6214,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. @@ -6263,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 @@ -9067,6 +9067,7 @@ fn completed_user_block(command: &str) -> BlockType { 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, @@ -9365,3 +9366,142 @@ 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" + ); + }); +} 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) } From f01d9f481bc2ec06897367ad4b2165a03ccde103 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 20:09:42 +0200 Subject: [PATCH 09/12] fix(pane-group): drop a pane's recorded agent session when the pane is gone for good MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recorded rows are keyed by pane uuid and nothing removed them when a pane was permanently discarded, so they accumulated for panes that will never return. Deletion hangs off the same pane-lifecycle branch that already purges a gone pane's per-uuid rows — `DetachType::Closed`, behind the same save-session guard — rather than off `save_app_state`, whose snapshot transaction is exactly the coupling the recorded state exists outside of. It is deliberately not folded into `delete_blocks`, which is also reached from clearing a block list and must not touch the agent row. `Closed` is reached only from the undo-stack discard paths. Closing a tab or a window detaches as `HiddenForClose`, so an undone close still owns its recording and still resumes, and app teardown clears nothing. This corrects a factual error in the capture unit's test: `clean_up_panes` is not app teardown — teardown reaches `detach_panes` and hides for close — so that assertion was pinning the opposite of what permanent removal requires. It now lives with the permanent-removal case, and the hide-for-close assertion carries the note that window close and teardown both take that path. The load-time sweep is a secondary safeguard only: it drops rows for pane uuids the saved snapshot does not restore, runs once per process before any pane exists, and is non-fatal so a locked or read-only database still restores every window. `SetAgentSession { session: None }` is reused rather than adding an event, so everything a pane says about its agent stays in one order and inherits the existing per-pane coalescing — which is what stops a stale save from resurrecting a deleted row. `detach` clears the attached flag first, so the clear is always the pane's last word. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/src/pane_group/mod_tests.rs | 132 ++++++++++++++++++++++- app/src/pane_group/pane/terminal_pane.rs | 28 +++++ app/src/persistence/mod.rs | 4 + app/src/persistence/sqlite.rs | 27 +++++ app/src/persistence/sqlite_tests.rs | 93 ++++++++++++++++ 5 files changed, 281 insertions(+), 3 deletions(-) diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index b8eb183a7f6..19f5015d0b1 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -3777,6 +3777,26 @@ fn only_terminal_pane_uuid(pane_group: &ViewHandle, app: &App) -> Vec }) } +/// 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. @@ -3909,7 +3929,8 @@ fn pane_detached_for_close_or_teardown_keeps_its_recorded_state() { "precondition: the pane recorded a session" ); - // Closing the tab hides its panes so an undo can bring them back. + // 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), @@ -3917,13 +3938,118 @@ fn pane_detached_for_close_or_teardown_keeps_its_recorded_state() { "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); - // Teardown detaches every pane before the writer is drained. + App::test((), |mut app| async move { + 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 { + 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 { + 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![], - "app teardown must not clear what its panes recorded" + "a pane that only moved is still running its agent and must keep what it recorded" ); }); } diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index e7d38e53acc..3052e51c1b4 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -237,6 +237,31 @@ impl TerminalPane { } } + /// Instructs the SQLite thread to drop whatever agent state was recorded for this session. + /// + /// Sent from the permanent-close branch of [`Self::detach`] only, and behind the same guard + /// [`Self::delete_blocks`] uses. A pane hidden for close comes back if the user undoes the + /// close, and what it recorded is exactly what resumes its agent then (R20) — only a pane + /// that will never return leaves a row that nothing can claim. + pub(in crate::pane_group) fn delete_recorded_agent_session(&self, ctx: &AppContext) { + if !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, @@ -433,6 +458,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. diff --git a/app/src/persistence/mod.rs b/app/src/persistence/mod.rs index ce4e60fb03f..d96c4cc8f73 100644 --- a/app/src/persistence/mod.rs +++ b/app/src/persistence/mod.rs @@ -353,6 +353,10 @@ pub enum ModelEvent { /// 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, diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index 5b305480b8a..8dacddfee57 100644 --- a/app/src/persistence/sqlite.rs +++ b/app/src/persistence/sqlite.rs @@ -1578,6 +1578,26 @@ fn clear_agent_session(conn: &mut SqliteConnection, pane_id: Vec) -> Result< 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 @@ -2807,6 +2827,13 @@ 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") + ); + } let recorded_agent_sessions = get_all_recorded_agent_sessions(conn)?; // Load active MCP servers from database diff --git a/app/src/persistence/sqlite_tests.rs b/app/src/persistence/sqlite_tests.rs index ab7f907c59a..07e33cb4105 100644 --- a/app/src/persistence/sqlite_tests.rs +++ b/app/src/persistence/sqlite_tests.rs @@ -1386,6 +1386,99 @@ fn agent_session_is_absent_for_pane_without_a_recorded_row() { 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"); From 450bc05b427911b4a1b06f7e85a6985619b1476f Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 20:49:46 +0200 Subject: [PATCH 10/12] feat(pane-group): report what happened to each restored pane's agent session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `AgentSessionResume.PaneRestore.Outcome`, emitted once per restored pane that carried recorded state, so the feature's field behavior is measurable before the flag is promoted. The payload is four closed values — agent kind, outcome, whether permission posture flags were carried, and a coarse age band. No `String` reaches it at all, so no invocation text, flag value, session identifier, or path can be carried; `contains_ugc` is false. A test stuffs a recording with sensitive markers, destructures the event exhaustively so a later field cannot slip through unreviewed, and asserts the serialized payload contains no marker, no `/`, and no `--`, with the key set pinned exactly. A pane that was not running an agent reports nothing, structurally as well as by outcome mapping — otherwise every ordinary pane would emit. The age bands put R22's provisional 12-hour window on a band edge rather than inside a band, so the distribution can answer what moving the window to 6h or 24h would cost rather than only how the guess did. Bands close at their upper edge, matching the freshness rule itself, so the carrying population is exactly the bands up to the window. A recording dated in the future gets its own band instead of being folded in with the oldest, where it would read as false evidence for a shorter window. Posture-carried is reported only when the pane actually resumed: the posture is computable for every pane, including ones that armed nothing, so reporting it verbatim would claim flags were carried where nothing ran. The field means the recording was fresh enough for its flags to ride along, not that flags existed and survived validation — the builder drops invalid flags internally and the call site cannot see which survived. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/src/pane_group/mod.rs | 21 ++ app/src/pane_group/mod_tests.rs | 411 +++++++++++++++++++++++++++++++- app/src/pane_group/telemetry.rs | 248 +++++++++++++++++++ 3 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 app/src/pane_group/telemetry.rs diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 2acff90814a..24df8cc651f 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -179,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"] @@ -1865,6 +1867,25 @@ impl PaneGroup { } }; + // 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(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 diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 19f5015d0b1..1ad7dd3e49e 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -12,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}; @@ -30,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; @@ -86,7 +90,9 @@ use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::system::SystemStats; use crate::terminal::alt_screen_reporting::AltScreenReporting; -use crate::terminal::cli_agent_resume::RESUME_HISTORY_MARKER; +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, @@ -4962,3 +4968,406 @@ fn an_eligible_pane_restores_the_same_way_an_unrecorded_one_does() { 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: crate::terminal::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/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); From 38817481af4a0edf97117896ca4d37a530f79a73 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 21:33:39 +0200 Subject: [PATCH 11/12] perf(pane-group): stop paying for agent-resume work that is thrown away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of waste found reviewing the feature, plus dead code. The capture path did a grid walk with secret redaction, an alias resolution, a flag extraction, and a working-directory stat on every firing of an event the codebase documents as firing once per tool call — hundreds of times per agent task — only for a same-value guard a few lines later to discard nearly all of it. The pair naming the conversation holds for the whole task, so the burst now settles on a map lookup and a short string compare, and the expensive read runs only when that pair actually changes. The original guard stays as the final check, since flags and directory can move while identity does not. Startup paid `is_dir` and a canonicalization per restored pane with a recorded session even when the feature flag was off — which is every user today. The telemetry that follows is independently gated on the same flag, so with it off that work had no observable effect at all. The gate now runs before the filesystem work, and deliberately at the call site rather than inside the eligibility function, so the unit tests that exercise the gate directly still see real verdicts. The restored directory was stat-ed twice: once by the caller filtering on `is_dir`, then again inside the shared resolver. Verification and canonicalization are now separate, so the recorded directory — whose existence is genuinely unknown and is what R15 turns into an ineligibility reason — is still checked, while the already-verified side is only canonicalized. Both sides still canonicalize, which is what makes symlinked paths such as macOS `/tmp` against `/private/tmp` compare equal. `AgentSessionRestore::recorded_on_startup` is removed: it was superseded within this branch by the eligibility gate, which cannot use it because it needs the absent-recording and not-a-startup-pass cases as distinct outcomes that a single collapsed `Option` cannot express. Nothing outside tests called it. A doc block describing the command builder had drifted onto the neighbouring posture-flag accessor; it is moved back, and that accessor now says why it exists so it is not deleted as unused — it is what forces a newly declared permission-posture flag to be acknowledged before it can ship. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/src/app_state.rs | 7 --- app/src/app_state_tests.rs | 48 -------------------- app/src/pane_group/mod.rs | 56 ++++++++++++++---------- app/src/pane_group/mod_tests.rs | 2 +- app/src/pane_group/pane/terminal_pane.rs | 53 +++++++++++++++++----- app/src/terminal/cli_agent_resume.rs | 16 ++++--- 6 files changed, 87 insertions(+), 95 deletions(-) diff --git a/app/src/app_state.rs b/app/src/app_state.rs index e86fe73a20f..c608aff9cfb 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -71,13 +71,6 @@ pub struct AgentSessionRestore { } impl AgentSessionRestore { - /// The state recorded for `pane_uuid`, and only on the startup restore pass. - pub fn recorded_on_startup(&self, pane_uuid: &PaneUuid) -> Option<&RecordedAgentSession> { - self.is_startup_restore - .then(|| self.sessions.get(pane_uuid)) - .flatten() - } - /// 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) diff --git a/app/src/app_state_tests.rs b/app/src/app_state_tests.rs index 4bc863eb567..d5eaf3063f4 100644 --- a/app/src/app_state_tests.rs +++ b/app/src/app_state_tests.rs @@ -106,51 +106,3 @@ fn test_code_pane_snapshot_with_multiple_tabs() { assert_eq!(tabs[2].path, None); assert!(matches!(source, Some(CodeSource::Link { .. }))); } - -fn recorded_session() -> RecordedAgentSession { - 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"), - } -} - -fn startup_restore(pane_uuid: Vec) -> AgentSessionRestore { - AgentSessionRestore { - sessions: Arc::new(HashMap::from([( - PaneUuid(pane_uuid.clone()), - recorded_session(), - )])), - claimed_panes: Arc::new(HashSet::from([PaneUuid(pane_uuid)])), - is_startup_restore: true, - } -} - -#[test] -fn recorded_session_is_found_by_the_uuid_the_pane_reports() { - let restore = startup_restore(vec![4, 2]); - - assert_eq!( - restore.recorded_on_startup(&PaneUuid(vec![4, 2])), - Some(&recorded_session()) - ); - assert_eq!(restore.recorded_on_startup(&PaneUuid(vec![4, 3])), None); -} - -// Adding a tab from a snapshot mid-session walks the same restore path as startup, and resuming -// an agent there would relaunch something the user never had running in that tab. -#[test] -fn recorded_session_is_withheld_when_the_restore_is_not_the_startup_pass() { - let mut restore = startup_restore(vec![4, 2]); - restore.is_startup_restore = false; - - assert_eq!(restore.recorded_on_startup(&PaneUuid(vec![4, 2])), None); -} diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 24df8cc651f..5b93b1ad3bb 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -1225,7 +1225,9 @@ fn collect_terminal_leaves<'a>( /// `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. +/// 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, @@ -1257,9 +1259,9 @@ pub(crate) fn resume_eligibility<'a>( return Err(ResumeIneligibility::SessionNotLocal); } - let recorded_directory = resolved_directory(&recorded.directory) + let recorded_directory = existing_directory(&recorded.directory) .ok_or(ResumeIneligibility::RecordedDirectoryMissing)?; - if restored_directory.and_then(resolved_directory) != Some(recorded_directory) { + if restored_directory.map(canonical_directory) != Some(recorded_directory) { return Err(ResumeIneligibility::RestoredElsewhere); } @@ -1288,12 +1290,17 @@ fn resume_invocation_for(recorded: &RecordedAgentSession) -> Option { ) } -/// `path` as it resolves on disk right now, or `None` when nothing is there. Both sides of a -/// directory comparison 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 resolved_directory(path: &Path) -> Option { - path.is_dir() - .then(|| dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())) +/// `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)] @@ -1842,16 +1849,20 @@ impl PaneGroup { .filter(|path| path.is_dir()); // The verdict is decided here, where the directory the pane is about to come up - // in is known. - let resume_verdict = resume_eligibility( - &agent_restore, - &uuid, - &terminal_snapshot, - startup_directory.as_deref(), - ); + // 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 { - Ok(_) if !FeatureFlag::AgentSessionResume.is_enabled() => None, - Ok(recorded) => { + None => None, + Some(Ok(recorded)) => { log::info!( "Restored pane can resume its recorded {:?} agent session", recorded.agent @@ -1860,8 +1871,8 @@ impl PaneGroup { } // The ordinary outcome for every pane that was not running an agent, so // reporting it would say nothing about this feature. - Err(ResumeIneligibility::NoRecordedSession) => None, - Err(reason) => { + Some(Err(ResumeIneligibility::NoRecordedSession)) => None, + Some(Err(reason)) => { log::info!("Restored pane will not resume an agent session: {reason:?}"); None } @@ -1872,9 +1883,10 @@ impl PaneGroup { // 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(recorded) = agent_restore.sessions.get(&uuid) + 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()) + ResumeOutcome::for_verdict(resume_verdict, resume_command.is_some()) { send_telemetry_from_ctx!( AgentSessionResumeTelemetryEvent::pane_restored( diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 1ad7dd3e49e..68e7ce87b53 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -3619,7 +3619,7 @@ fn restored_terminal_pane_reports_the_uuid_its_recorded_session_is_keyed_by() { }); assert_eq!( - agent_restore.recorded_on_startup(&PaneUuid(reported_uuid)), + agent_restore.sessions.get(&PaneUuid(reported_uuid)), Some(&recorded) ); }); diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 3052e51c1b4..218097f2d6a 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -740,12 +740,31 @@ fn capture_agent_session( let session = match event { CLIAgentSessionsModelEvent::SessionUpdated { .. } | CLIAgentSessionsModelEvent::StatusChanged { .. } => { - match observed_agent_session(group, terminal_pane_id, event.terminal_view_id(), ctx) { - // Nothing to record until the agent has reported an identifier: a recording - // without one claims no session and resumes nothing. - None => return, - session => session, + // 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 — @@ -808,14 +827,17 @@ fn records_same_agent_session( } } -/// The agent state to record for `terminal_pane_id`, or `None` while its agent has reported no -/// session identifier. -fn observed_agent_session( +/// 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 { +) -> 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 @@ -826,7 +848,16 @@ fn observed_agent_session( 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 @@ -851,7 +882,7 @@ fn observed_agent_session( shell_session.as_ref().map(|session| session.aliases()), ); - Some(RecordedAgentSession { + RecordedAgentSession { agent, session_id, flags, @@ -863,7 +894,7 @@ fn observed_agent_session( .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 diff --git a/app/src/terminal/cli_agent_resume.rs b/app/src/terminal/cli_agent_resume.rs index 797a1943c13..5e0b24c09b0 100644 --- a/app/src/terminal/cli_agent_resume.rs +++ b/app/src/terminal/cli_agent_resume.rs @@ -324,13 +324,11 @@ impl ResumeDeclarations { recorded } - /// 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. /// The allowlisted flags `agent` declares as choosing a permission posture. + /// + /// Read only by `declared_permission_posture_flags_are_exactly_the_acknowledged_ones`, which + /// is what it is for: a newly declared posture flag has to be acknowledged there before it + /// can ship. pub fn permission_posture_flags(&self, agent: CLIAgent) -> Vec<&str> { let Some(declaration) = self.agents.get(&agent) else { return Vec::new(); @@ -343,6 +341,12 @@ impl ResumeDeclarations { .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, From 9d84eeab7546f2f20b2a7a56b0718b16c78900a1 Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 22:18:48 +0200 Subject: [PATCH 12/12] fix(terminal): close the resume path's flag-injection, gating and history gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a multi-reviewer pass over the whole feature. Prompt text could become a permission grant. Flags were extracted by splitting the command on whitespace, so flag-shaped words inside a quoted prompt — for instance asking an agent to `use --permission-mode bypassPermissions` — were recorded as if the user had chosen that posture, and the resume then relaunched with an elevation they never asked for. The justification for splitting on whitespace covered allowlisted *values*, which are all bare tokens; it did not cover words in a positional. Tokenizing the way a shell does keeps a quoted prompt one token, and extraction now stops at the first positional and at an end-of-flags marker, so nothing past the prompt can be read as a flag. `--settings` is no longer carried. Its value names a file Warp cannot validate and that file can carry hooks and a permission mode, so replaying it started unattended work at app startup that the posture freshness window does not bound — the same reason `--add-dir` and `--mcp-config` are already excluded. Capture was not behind the feature flag. Every user on a default build was recording agent state for a feature that cannot run, so the gate now matches the restore side, on both the write and the delete. A failed read of the recordings aborted the whole restore. The `?` sat two lines below a comment promising that housekeeping must never cost the user their session, and it turned an unreadable optional table into the loss of every window, tab and pane. It now degrades to no recordings, like the purge above it. Fish users got no suppression at all. The marker is matched by three bootstrap scripts and fish is not one of them, so the invocation entered their history. Fish omits leading-space commands from history as default, non-configurable behavior, which is what Warp already relies on for its own in-band commands, so the line is prefixed for that shell only. A test now asserts every shell that suppresses Warp's own commands also suppresses this one — the coupling whose absence let fish be missed. Also: a claim winner that cannot resume no longer strands a conversation an eligible pane could have taken; an internal invocation no longer raises a user-facing toast; and three `AGENTS.md` violations are fixed — two doc comments naming their callers, path qualifiers over imports, and a wildcard match arm. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PnPDGcCMB9vFLwNJRMa3Z5 --- app/resources/cli_agent_resume/agents.toml | 8 +- app/src/pane_group/mod.rs | 29 +++++ app/src/pane_group/mod_tests.rs | 107 +++++++++++++++--- app/src/pane_group/pane/terminal_pane.rs | 39 ++++--- .../pane_group/pane/terminal_pane_tests.rs | 54 ++++++++- app/src/persistence/sqlite.rs | 10 +- app/src/terminal/cli_agent_resume.rs | 40 +++++-- app/src/terminal/cli_agent_resume_tests.rs | 99 +++++++++++++++- app/src/terminal/input.rs | 6 +- app/src/terminal/view.rs | 5 + app/src/terminal/view_tests.rs | 52 +++++++++ 11 files changed, 404 insertions(+), 45 deletions(-) diff --git a/app/resources/cli_agent_resume/agents.toml b/app/resources/cli_agent_resume/agents.toml index 58691d16477..b03ab6c1a10 100644 --- a/app/resources/cli_agent_resume/agents.toml +++ b/app/resources/cli_agent_resume/agents.toml @@ -37,6 +37,13 @@ # 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 @@ -61,7 +68,6 @@ identifier = { shape = "bare_token", max_length = 128 } "--dangerously-skip-permissions" = { shape = "boolean", permission_posture = true } "--strict-mcp-config" = { shape = "boolean" } "--agent" = { shape = "bare_token", max_length = 64 } -"--settings" = { shape = "path_like", max_length = 512 } [agents.Codex] # `codex resume `, the shape Warp's own headless driver already diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 5b93b1ad3bb..de86f4f256c 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -1158,6 +1158,12 @@ pub(crate) fn resolve_agent_session_claims( 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, @@ -1189,6 +1195,29 @@ pub(crate) fn resolve_agent_session_claims( 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)] diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 68e7ce87b53..8c4427810a0 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -89,6 +89,7 @@ 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_resume::{ PERMISSION_POSTURE_FRESHNESS, RESUME_HISTORY_MARKER, RecordedFlag, @@ -3551,10 +3552,10 @@ fn restored_terminal_pane_reports_the_uuid_its_recorded_session_is_keyed_by() { initialize_app(&mut app); let pane_uuid = vec![7, 7, 7]; - let recorded = crate::app_state::RecordedAgentSession { - agent: crate::terminal::CLIAgent::Claude, + let recorded = app_state::RecordedAgentSession { + agent: CLIAgent::Claude, session_id: "session-1".to_owned(), - flags: vec![crate::terminal::cli_agent_resume::RecordedFlag { + flags: vec![RecordedFlag { name: "--model".to_owned(), value: Some("opus".to_owned()), }], @@ -3656,7 +3657,7 @@ fn pane_group_reporting_model_events( /// has been the pane's foreground command for long enough. fn start_cli_agent_session( terminal_view_id: EntityId, - agent: crate::terminal::CLIAgent, + agent: CLIAgent, ctx: &mut ViewContext, ) { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions, ctx| { @@ -3809,6 +3810,7 @@ fn terminal_pane_uuid(pane_group: &ViewHandle, pane_id: PaneId, app: #[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); @@ -3818,7 +3820,7 @@ fn pane_records_each_identifier_its_agent_reports_in_order() { .active_session_view(ctx) .expect("the group should have an active terminal view") .id(); - start_cli_agent_session(terminal_view_id, crate::terminal::CLIAgent::Claude, ctx); + start_cli_agent_session(terminal_view_id, CLIAgent::Claude, ctx); terminal_view_id }); @@ -3851,7 +3853,7 @@ fn record_agent_session_in_pane( let terminal_view = panes .active_session_view(ctx) .expect("the group should have an active terminal view"); - start_cli_agent_session(terminal_view.id(), crate::terminal::CLIAgent::Claude, ctx); + start_cli_agent_session(terminal_view.id(), CLIAgent::Claude, ctx); terminal_view }); let terminal_view_id = terminal_view.id(); @@ -3867,6 +3869,7 @@ fn record_agent_session_in_pane( #[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); @@ -3890,6 +3893,7 @@ fn pane_whose_agent_exited_records_that_it_has_nothing_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); @@ -3925,6 +3929,7 @@ fn pane_whose_agent_is_suspended_keeps_its_recorded_state() { #[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); @@ -3955,6 +3960,7 @@ 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); @@ -4003,6 +4009,7 @@ fn undone_close_leaves_the_pane_still_owning_its_recorded_state() { #[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); @@ -4030,6 +4037,7 @@ fn permanently_removed_pane_has_its_recorded_state_cleared() { #[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); @@ -4066,6 +4074,7 @@ fn pane_moved_out_of_its_group_keeps_its_recorded_state() { #[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); @@ -4094,6 +4103,7 @@ fn burst_of_tool_call_events_from_one_agent_collapses_to_one_write() { #[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); @@ -4101,7 +4111,7 @@ fn pane_that_replaced_its_agent_does_not_record_an_absent_session() { let _ = captured_agent_session_writes(&model_events); pane_group.update(&mut app, |_, ctx| { - start_cli_agent_session(terminal_view_id, crate::terminal::CLIAgent::Codex, ctx); + start_cli_agent_session(terminal_view_id, CLIAgent::Codex, ctx); }); assert_eq!( @@ -4118,6 +4128,7 @@ fn pane_that_replaced_its_agent_does_not_record_an_absent_session() { #[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); @@ -4158,6 +4169,7 @@ fn pane_without_a_local_directory_records_none_and_stays_ineligible() { #[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 @@ -4180,6 +4192,31 @@ fn pane_records_nothing_when_session_restore_is_off() { }); } +// 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 @@ -4187,6 +4224,7 @@ fn pane_records_nothing_when_session_restore_is_off() { #[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); @@ -4199,7 +4237,7 @@ fn recorded_agent_session_carries_no_prompt_response_summary_or_tool_preview() { sessions.set_session( terminal_view_id, CLIAgentSession { - agent: crate::terminal::CLIAgent::Claude, + agent: CLIAgent::Claude, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext { cwd: Some("SENSITIVE-cwd".to_owned()), @@ -4259,7 +4297,7 @@ fn recorded_agent_session_carries_no_prompt_response_summary_or_tool_preview() { /// 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: crate::terminal::CLIAgent::Claude, + agent: CLIAgent::Claude, session_id: session_id.to_owned(), flags: vec![], directory: directory.to_path_buf(), @@ -4306,15 +4344,15 @@ fn startup_restore_for_test( /// reason about without a window existing. fn window_snapshot_for_test(panes: Vec) -> WindowSnapshot { WindowSnapshot { - tabs: vec![crate::app_state::TabSnapshot { + tabs: vec![app_state::TabSnapshot { custom_title: None, root: PaneNodeSnapshot::Branch(BranchSnapshot { - direction: crate::app_state::SplitDirection::Horizontal, + direction: app_state::SplitDirection::Horizontal, children: panes .into_iter() .map(|pane| { ( - crate::app_state::PaneFlex(1.), + app_state::PaneFlex(1.), PaneNodeSnapshot::Leaf(LeafSnapshot { is_focused: false, custom_vertical_tabs_title: None, @@ -4470,6 +4508,41 @@ fn resume_claims_go_to_the_pane_in_the_window_the_user_lands_in() { 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] @@ -4541,7 +4614,7 @@ 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 = crate::terminal::CLIAgent::Gemini; + recorded.agent = CLIAgent::Gemini; let agent_restore = startup_restore_for_test([(pane_uuid.clone(), recorded)], [pane_uuid.clone()]); @@ -4652,7 +4725,7 @@ fn every_resume_rejection_carries_its_own_reason() { let mut without_identifier = recorded.clone(); without_identifier.session_id = String::new(); let mut undeclared_agent = recorded.clone(); - undeclared_agent.agent = crate::terminal::CLIAgent::Gemini; + undeclared_agent.agent = CLIAgent::Gemini; let missing_directory = recorded_session_for_test("session-1", &directory.path().join("gone")); let claimed = @@ -4802,7 +4875,7 @@ fn restored_panes_with_armed_resume( .into_iter() .map(|pane| { ( - crate::app_state::PaneFlex(1.), + app_state::PaneFlex(1.), PaneNodeSnapshot::Leaf(LeafSnapshot { is_focused: false, custom_vertical_tabs_title: None, @@ -4812,7 +4885,7 @@ fn restored_panes_with_armed_resume( }) .collect(); let layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Branch(BranchSnapshot { - direction: crate::app_state::SplitDirection::Horizontal, + direction: app_state::SplitDirection::Horizontal, children, }))); @@ -5169,7 +5242,7 @@ fn a_resume_reports_the_recorded_age_in_bracketing_bands() { #[test] fn the_reported_resume_outcome_carries_nothing_of_the_session() { let recorded = RecordedAgentSession { - agent: crate::terminal::CLIAgent::Claude, + agent: CLIAgent::Claude, session_id: "SENSITIVE-session-id".to_owned(), flags: vec![RecordedFlag { name: "--SENSITIVE-flag".to_owned(), diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 218097f2d6a..6e3b22567b0 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -14,6 +14,7 @@ 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::{ @@ -239,12 +240,13 @@ impl TerminalPane { /// Instructs the SQLite thread to drop whatever agent state was recorded for this session. /// - /// Sent from the permanent-close branch of [`Self::detach`] only, and behind the same guard - /// [`Self::delete_blocks`] uses. A pane hidden for close comes back if the user undoes the - /// close, and what it recorded is exactly what resumes its agent then (R20) — only a pane - /// that will never return leaves a row that nothing can claim. + /// 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 !AppExecutionMode::as_ref(ctx).can_save_session() { + if !FeatureFlag::AgentSessionResume.is_enabled() + || !AppExecutionMode::as_ref(ctx).can_save_session() + { return; } @@ -719,9 +721,12 @@ fn capture_agent_session( return; } - // 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 !*GeneralSettings::as_ref(ctx).restore_session + // 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; @@ -778,7 +783,11 @@ fn capture_agent_session( } None } - _ => return, + // 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(); @@ -917,10 +926,14 @@ fn recorded_resume_flags( return Vec::new(); } - // Splitting on whitespace splits a quoted value too, but every shape the allowlist declares - // is a bare token, so a value that needed quoting was never one a resume could carry. - let args = resolved.split_whitespace().skip(1).collect::>(); - ResumeDeclarations::embedded().extract_resume_flags(agent, &args) + // 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 { diff --git a/app/src/pane_group/pane/terminal_pane_tests.rs b/app/src/pane_group/pane/terminal_pane_tests.rs index 42775ee690c..3d1f1a7d87a 100644 --- a/app/src/pane_group/pane/terminal_pane_tests.rs +++ b/app/src/pane_group/pane/terminal_pane_tests.rs @@ -70,6 +70,56 @@ fn recorded_flags_include_the_flags_an_alias_carries() { ); } +// 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. @@ -94,12 +144,12 @@ fn recorded_flags_are_empty_when_the_command_is_not_the_agent() { fn recorded_flags_carry_the_obfuscated_placeholder_rather_than_a_secret() { let recorded = recorded_resume_flags( CLIAgent::Claude, - "claude --settings ********", + "claude --model ********", Some(EscapeChar::Backslash), None, ); - assert_eq!(recorded, vec![flag("--settings", Some("********"))]); + assert_eq!(recorded, vec![flag("--model", Some("********"))]); assert!( ResumeDeclarations::embedded() .build_resume_command( diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index 8dacddfee57..b095eddf261 100644 --- a/app/src/persistence/sqlite.rs +++ b/app/src/persistence/sqlite.rs @@ -2834,7 +2834,15 @@ fn read_sqlite_data( anyhow::Error::new(err).context("Error purging orphaned agent session rows") ); } - let recorded_agent_sessions = get_all_recorded_agent_sessions(conn)?; + // 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)?; diff --git a/app/src/terminal/cli_agent_resume.rs b/app/src/terminal/cli_agent_resume.rs index 5e0b24c09b0..ba5cf62a2f2 100644 --- a/app/src/terminal/cli_agent_resume.rs +++ b/app/src/terminal/cli_agent_resume.rs @@ -21,13 +21,16 @@ 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 the bootstrap scripts in `app/assets/bundled/bootstrap/` -/// (`zsh_body.sh`, `bash_body.sh`, `pwsh.ps1`); changing this string means changing -/// all three. `#` starts a comment in all three shells, so the marker stays inert. +/// 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"); @@ -280,8 +283,15 @@ impl ResumeDeclarations { while index < args.len() { let arg = args[index].as_ref(); index += 1; - if !arg.starts_with('-') { - continue; + // 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('=') { @@ -326,9 +336,9 @@ impl ResumeDeclarations { /// The allowlisted flags `agent` declares as choosing a permission posture. /// - /// Read only by `declared_permission_posture_flags_are_exactly_the_acknowledged_ones`, which - /// is what it is for: a newly declared posture flag has to be acknowledged there before it - /// can ship. + /// 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(); @@ -531,6 +541,20 @@ fn is_flag_spelling(candidate: &str) -> bool { && 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 diff --git a/app/src/terminal/cli_agent_resume_tests.rs b/app/src/terminal/cli_agent_resume_tests.rs index b6055cc60ac..f7c87e9b8b6 100644 --- a/app/src/terminal/cli_agent_resume_tests.rs +++ b/app/src/terminal/cli_agent_resume_tests.rs @@ -297,7 +297,7 @@ fn dropping_every_flag_still_builds_a_bare_resume() { let command = claude_command(&[ flag("--model", Some("sonnet;pwn")), flag("--permission-mode", Some("plan pwn")), - flag("--settings", Some("$(pwn)")), + flag("--agent", Some("$(pwn)")), ]); assert_eq!( @@ -421,6 +421,69 @@ fn built_invocation_carries_the_history_marker() { ); } +/// R18: the marker suppresses nothing on its own — it suppresses where a bootstrap script matches +/// it. Pinning the constant without reading the scripts is what let a shell ship with the resume +/// landing in the user's history file, so this asks the scripts themselves. +/// +/// The question is per shell, not per script: a script that can be told to omit Warp's own in-band +/// generator from history can be told to omit the resume too, and one that cannot needs the other +/// mechanism instead. +#[test] +fn every_shell_that_suppresses_warps_own_commands_suppresses_the_resume() { + const BASH: &str = include_str!("../../assets/bundled/bootstrap/bash_body.sh"); + const ZSH: &str = include_str!("../../assets/bundled/bootstrap/zsh_body.sh"); + const PWSH: &str = include_str!("../../assets/bundled/bootstrap/pwsh.ps1"); + const FISH: &str = include_str!("../../assets/bundled/bootstrap/fish.sh"); + + // Each script paired with how it spells the in-band generator command it already keeps out of + // history, which is the evidence that this shell takes history patterns at all. + for (script, contents, in_band_command) in [ + ("bash_body.sh", BASH, "warp_run_generator_command"), + ("zsh_body.sh", ZSH, "warp_run_generator_command"), + ("pwsh.ps1", PWSH, "Warp-Run-GeneratorCommand"), + ] { + assert!( + contents.contains(in_band_command), + "precondition: {script} filters {in_band_command} out of history" + ); + assert!( + contents.contains(RESUME_HISTORY_MARKER), + "{script} filters {in_band_command} but not the resume marker, so a resume run under \ + this shell lands in the user's history file" + ); + } + + // Fish takes no history patterns at all, which is why it matches no marker. Its mechanism is + // stated here rather than left as an exemption, so removing it fails this test. + assert!( + FISH.contains("warp_run_generator_command"), + "precondition: fish runs the same in-band generator command" + ); + assert!( + !FISH.contains(RESUME_HISTORY_MARKER), + "fish gained a marker filter; the leading space this asserts below may now be redundant" + ); + let command = claude_command(&[]); + assert!( + history_suppressed_resume_command(Some(ShellType::Fish), command.clone()).starts_with(' '), + "fish omits leading-space commands from history, and that is all it offers" + ); + for shell in [ShellType::Bash, ShellType::Zsh, ShellType::PowerShell] { + assert_eq!( + history_suppressed_resume_command(Some(shell), command.clone()), + command, + "{shell:?} matches the marker and needs nothing added to the line" + ); + } + assert_eq!( + history_suppressed_resume_command(None, command.clone()), + command, + "a shell Warp cannot identify keeps the line it built" + ); +} + +// The undeclared flag that carries a value sits last on purpose: its value is indistinguishable +// from a positional, and scanning stops at the first of those. #[test] fn extractor_keeps_only_allowlisted_flags() { let recorded = declarations().extract_resume_flags( @@ -429,9 +492,9 @@ fn extractor_keeps_only_allowlisted_flags() { "--model", "sonnet", "--fork-session", + "--dangerously-skip-permissions", "--session-id", "11111111-2222-3333-4444-555555555555", - "--dangerously-skip-permissions", "write me a test", ], ); @@ -445,6 +508,38 @@ fn extractor_keeps_only_allowlisted_flags() { ); } +/// A prompt positional ends the flags, so the words after it are the user's text rather than +/// choices they made. Both barriers are needed: the caller tokenizes so the prompt arrives as one +/// word, and this stops at it so an agent that took a bare positional cannot leak flags either. +#[test] +fn extractor_stops_at_the_first_positional() { + assert_eq!( + declarations().extract_resume_flags( + CLIAgent::Claude, + &[ + "--model", + "opus", + "fix the build", + "--dangerously-skip-permissions", + ], + ), + vec![flag("--model", Some("opus"))] + ); +} + +/// `--` is the same statement made explicitly, and an agent that honors it would read what +/// follows as text however flag-shaped it is. +#[test] +fn extractor_stops_at_an_end_of_flags_marker() { + assert_eq!( + declarations().extract_resume_flags( + CLIAgent::Claude, + &["--model", "opus", "--", "--dangerously-skip-permissions"], + ), + vec![flag("--model", Some("opus"))] + ); +} + #[test] fn extractor_normalizes_the_equals_form() { let recorded = declarations() diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 2883870bfe6..06f45417637 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -7568,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); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index cfed5d6a0e1..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, @@ -16005,6 +16006,10 @@ impl TerminalView { 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); }); diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 945d94bb7ca..6924f3e2699 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -9505,3 +9505,55 @@ fn resume_block_completing_leaves_a_draft_alone() { ); }); } + +/// 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" + ); + }); + }); +}