diff --git a/app/Cargo.toml b/app/Cargo.toml index b561fa7ff..740316757 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -928,6 +928,7 @@ codex_notifications = [] cloud_mode_setup_v2 = ["cloud_mode"] cloud_mode_input_v2 = ["cloud_mode"] configurable_context_window = [] +handoff_cloud_cloud = ["cloud_mode_setup_v2"] [package.metadata.bundle.bin.warp-oss] category = "public.app-category.developer-tools" diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index 89e16f603..7c768bf4d 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -528,13 +528,15 @@ impl ConversationOrTask<'_> { /// Returns the session ID for tasks, if we have one. pub fn session_id(&self) -> Option { match self { - ConversationOrTask::Task(task) => task.session_id.as_ref().and_then(|s| { - let session_id = s.parse::(); - if let Err(ref e) = session_id { - log::warn!("Failed to parse shared session ID: {e}"); - } - session_id.ok() - }), + ConversationOrTask::Task(task) => { + task.active_run_execution().session_id.and_then(|s| { + let session_id = s.parse::(); + if let Err(ref e) = session_id { + log::warn!("Failed to parse shared session ID: {e}"); + } + session_id.ok() + }) + } ConversationOrTask::Conversation(_) => None, } } @@ -625,11 +627,12 @@ impl ConversationOrTask<'_> { fn link_preference(&self) -> LinkPreference { match self { ConversationOrTask::Task(task) => { + let run_execution = task.active_run_execution(); // Always open session link if there's a live session. // Without cloud conversations, also open session link as long as it's not expired. // With cloud conversations, even if the link is not expired, we load conversation // data from graphql as long as the session isn't live. - if task.is_sandbox_running + if run_execution.is_sandbox_running || (!FeatureFlag::CloudConversations.is_enabled() && self.get_session_status() != Some(SessionStatus::Expired)) { @@ -648,14 +651,16 @@ impl ConversationOrTask<'_> { pub fn session_or_conversation_link(&self, app: &AppContext) -> Option { match self.link_preference() { LinkPreference::Session => match self { - ConversationOrTask::Task(task) => task.session_link.clone(), + ConversationOrTask::Task(task) => task + .active_run_execution() + .session_link + .map(ToString::to_string), ConversationOrTask::Conversation(_) => None, }, LinkPreference::Conversation => match self { ConversationOrTask::Task(task) => task - .conversation_id - .as_ref() - .map(|id| ServerConversationToken::new(id.clone()).conversation_link()), + .conversation_id() + .map(|id| ServerConversationToken::new(id.to_string()).conversation_link()), ConversationOrTask::Conversation(conversation) => { let history_model = BlocklistAIHistoryModel::as_ref(app); history_model @@ -680,7 +685,7 @@ impl ConversationOrTask<'_> { if FeatureFlag::CloudConversations.is_enabled() { return match self { ConversationOrTask::Task(task) => { - if task.session_link.is_some() { + if task.active_run_execution().session_link.is_some() { Some(SessionStatus::Available) } else { Some(SessionStatus::Unavailable) @@ -691,7 +696,7 @@ impl ConversationOrTask<'_> { } match self { ConversationOrTask::Task(task) => { - if task.session_id.is_some() { + if task.active_run_execution().session_id.is_some() { Some(SessionStatus::Available) } else if (Utc::now() - task.created_at) > SESSION_EXPIRATION_TIME { Some(SessionStatus::Expired) @@ -778,16 +783,16 @@ impl ConversationOrTask<'_> { self.session_id() .map(|session_id| WorkspaceAction::OpenAmbientAgentSession { session_id, - task_id: task.task_id, + task_id: task.run_id(), }) } ConversationOrTask::Conversation(_) => None, }, LinkPreference::Conversation => match self { - ConversationOrTask::Task(task) => task.conversation_id.as_ref().map(|id| { + ConversationOrTask::Task(task) => task.conversation_id().map(|id| { WorkspaceAction::OpenConversationTranscriptViewer { - conversation_id: ServerConversationToken::new(id.clone()), - ambient_agent_task_id: Some(task.task_id), + conversation_id: ServerConversationToken::new(id.to_string()), + ambient_agent_task_id: Some(task.run_id()), } }), ConversationOrTask::Conversation(metadata) => { @@ -1124,7 +1129,7 @@ impl AgentConversationsModel { // Collect all conversation IDs from tasks let task_conversation_ids: HashSet = tasks .iter() - .filter_map(|task| task.conversation_id.clone()) + .filter_map(|task| task.conversation_id().map(str::to_string)) .collect(); // Build a set of conversation IDs we already have @@ -1381,11 +1386,11 @@ impl AgentConversationsModel { history_model: &BlocklistAIHistoryModel, ) -> Option { history_model - .conversation_id_for_agent_id(&task.task_id.to_string()) + .conversation_id_for_agent_id(&task.run_id().to_string()) .or_else(|| { - task.conversation_id.as_ref().and_then(|conversation_id| { + task.conversation_id().and_then(|conversation_id| { history_model.find_conversation_id_by_server_token( - &ServerConversationToken::new(conversation_id.clone()), + &ServerConversationToken::new(conversation_id.to_string()), ) }) }) diff --git a/app/src/ai/ambient_agents/spawn.rs b/app/src/ai/ambient_agents/spawn.rs index 8a6dc06e5..df47499ea 100644 --- a/app/src/ai/ambient_agents/spawn.rs +++ b/app/src/ai/ambient_agents/spawn.rs @@ -31,13 +31,13 @@ pub struct SessionJoinInfo { impl SessionJoinInfo { pub fn from_task(task: &AmbientAgentTask) -> Option { + let run_execution = task.active_run_execution(); // Prefer the server-provided session_link when available; it is a better signal // that a session-sharing link is ready to be shown to the user. - if let Some(link) = task.session_link.as_ref().filter(|l| !l.is_empty()) { - let session_id = task + if let Some(link) = run_execution.session_link { + let session_id = run_execution .session_id - .as_deref() - .and_then(|s| SessionId::from_str(s).ok()); + .and_then(|session_id| SessionId::from_str(session_id).ok()); return Some(Self { session_id, session_link: link.to_string(), @@ -45,8 +45,8 @@ impl SessionJoinInfo { } // Fallback to constructing a link from the session_id. - if let Some(session_id_str) = task.session_id.as_deref() { - if let Ok(session_id) = SessionId::from_str(session_id_str) { + if let Some(session_id) = run_execution.session_id { + if let Ok(session_id) = SessionId::from_str(session_id) { return Some(Self { session_id: Some(session_id), session_link: shared_session::join_link(&session_id), diff --git a/app/src/ai/ambient_agents/spawn_tests.rs b/app/src/ai/ambient_agents/spawn_tests.rs index 12fd31868..58523bbde 100644 --- a/app/src/ai/ambient_agents/spawn_tests.rs +++ b/app/src/ai/ambient_agents/spawn_tests.rs @@ -4,9 +4,11 @@ use std::sync::{ }; use chrono::Utc; +use session_sharing_protocol::common::SessionId; use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskState}; use crate::server::server_api::ai::{MockAIClient, SpawnAgentResponse}; +use crate::terminal::shared_session; use super::{spawn_task, AmbientAgentEvent, SessionJoinInfo}; @@ -109,6 +111,35 @@ fn session_join_info_prefers_session_link_and_tolerates_missing_session_id() { assert!(join_info.session_id.is_none()); } +#[test] +fn session_join_info_falls_back_to_session_id() { + let session_id = SessionId::new(); + let task = task_with( + AmbientAgentTaskState::InProgress, + Some(session_id.to_string()), + None, + ); + + let join_info = SessionJoinInfo::from_task(&task).expect("expected join info"); + + assert_eq!(join_info.session_id, Some(session_id)); + assert_eq!( + join_info.session_link, + shared_session::join_link(&session_id) + ); +} + +#[test] +fn session_join_info_ignores_empty_link_and_invalid_session_id() { + let task = task_with( + AmbientAgentTaskState::InProgress, + Some("not-a-session-id".to_string()), + Some(String::new()), + ); + + assert_eq!(SessionJoinInfo::from_task(&task), None); +} + #[tokio::test] async fn poll_for_session_join_info_waits_until_link_is_available() { use futures::StreamExt; diff --git a/app/src/ai/ambient_agents/task.rs b/app/src/ai/ambient_agents/task.rs index 375b1d48a..572dfa87d 100644 --- a/app/src/ai/ambient_agents/task.rs +++ b/app/src/ai/ambient_agents/task.rs @@ -278,6 +278,14 @@ pub struct AmbientAgentTask { pub children: Vec, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RunExecution<'a> { + pub session_id: Option<&'a str>, + pub session_link: Option<&'a str>, + pub request_usage: Option<&'a RequestUsage>, + pub is_sandbox_running: bool, +} + /// Represents a single attachment input from the client (e.g., file upload) #[derive(Clone, Debug, Serialize)] pub struct AttachmentInput { @@ -296,10 +304,27 @@ pub struct TaskAttachment { } impl AmbientAgentTask { + pub fn run_id(&self) -> AmbientAgentTaskId { + self.task_id + } + + pub fn conversation_id(&self) -> Option<&str> { + self.conversation_id.as_deref() + } + + pub fn active_run_execution(&self) -> RunExecution<'_> { + RunExecution { + session_id: self.session_id.as_deref(), + session_link: self.session_link.as_deref().filter(|link| !link.is_empty()), + request_usage: self.request_usage.as_ref(), + is_sandbox_running: self.is_sandbox_running, + } + } + /// Total credits used (inference + compute). pub fn credits_used(&self) -> Option { - self.request_usage - .as_ref() + self.active_run_execution() + .request_usage .map(|u| (u.inference_cost.unwrap_or(0.0) + u.compute_cost.unwrap_or(0.0)) as f32) } @@ -317,7 +342,7 @@ impl AmbientAgentTask { /// Returns true if the underlying session for the ambient agent is no longer running. pub fn is_no_longer_running(&self) -> bool { - !self.is_sandbox_running && !self.state.is_working() + !self.active_run_execution().is_sandbox_running && !self.state.is_working() } } diff --git a/app/src/ai/conversation_details_panel.rs b/app/src/ai/conversation_details_panel.rs index 22662b0ab..301e7e3f0 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -206,11 +206,11 @@ impl ConversationDetailsData { fn directory_for_task(task: &AmbientAgentTask, app: &AppContext) -> Option { let history_model = BlocklistAIHistoryModel::as_ref(app); let conversation_id = history_model - .conversation_id_for_agent_id(&task.task_id.to_string()) + .conversation_id_for_agent_id(&task.run_id().to_string()) .or_else(|| { - task.conversation_id.as_ref().and_then(|conversation_id| { + task.conversation_id().and_then(|conversation_id| { history_model.find_conversation_id_by_server_token( - &ServerConversationToken::new(conversation_id.clone()), + &ServerConversationToken::new(conversation_id.to_string()), ) }) })?; @@ -325,7 +325,7 @@ impl ConversationDetailsData { .as_ref() .and_then(|config| config.environment_id.clone()); - let credits = task.request_usage.as_ref().and_then(|u| { + let credits = task.active_run_execution().request_usage.and_then(|u| { Some(CreditsInfo::AmbientConversation { inference: u.inference_cost? as f32, compute: u.compute_cost? as f32, @@ -348,12 +348,12 @@ impl ConversationDetailsData { ConversationDetailsData { mode: PanelMode::Task { - task_id: Some(task.task_id), + task_id: Some(task.run_id()), directory: Self::directory_for_task(task, app), display_status: Some(AgentRunDisplayStatus::from_task(task, app)), error_message, environment_id, - conversation_id: task.conversation_id.clone(), + conversation_id: task.conversation_id().map(str::to_string), }, title: task.title.clone(), created_at: Some(task.created_at.with_timezone(&Local)), diff --git a/app/src/lib.rs b/app/src/lib.rs index 8c36f3c53..d554d845d 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2871,6 +2871,8 @@ pub fn enabled_features() -> HashSet { FeatureFlag::CloudModeInputV2, #[cfg(feature = "configurable_context_window")] FeatureFlag::ConfigurableContextWindow, + #[cfg(feature = "handoff_cloud_cloud")] + FeatureFlag::HandoffCloudCloud, ]); flags diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index ad3e339fd..616340359 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -207,6 +207,11 @@ pub struct SpawnAgentRequest { pub referenced_attachments: Vec, } +#[derive(Debug, Clone, serde::Serialize)] +pub struct RunFollowupRequest { + pub message: String, +} + // --- Orchestrations V2 messaging types --- #[derive(Debug, Clone, serde::Serialize)] @@ -657,6 +662,10 @@ pub(crate) fn build_list_agent_runs_url(limit: i32, filter: &TaskListFilter) -> url } +pub(crate) fn build_run_followup_url(run_id: &AmbientAgentTaskId) -> String { + format!("agent/runs/{run_id}/followups") +} + struct ListRunsResponse { runs: Vec, } @@ -825,6 +834,12 @@ pub trait AIClient: 'static + Send + Sync { task_id: &AmbientAgentTaskId, ) -> anyhow::Result; + async fn submit_run_followup( + &self, + run_id: &AmbientAgentTaskId, + request: RunFollowupRequest, + ) -> anyhow::Result<(), anyhow::Error>; + async fn get_scheduled_agent_history( &self, schedule_id: &str, @@ -1454,6 +1469,15 @@ impl AIClient for ServerApi { Ok(response) } + async fn submit_run_followup( + &self, + run_id: &AmbientAgentTaskId, + request: RunFollowupRequest, + ) -> anyhow::Result<(), anyhow::Error> { + self.post_public_api_unit(&build_run_followup_url(run_id), &request) + .await + } + async fn get_scheduled_agent_history( &self, schedule_id: &str, diff --git a/app/src/server/server_api/ai_test.rs b/app/src/server/server_api/ai_test.rs index ed188c61b..1a859dbf7 100644 --- a/app/src/server/server_api/ai_test.rs +++ b/app/src/server/server_api/ai_test.rs @@ -2,9 +2,10 @@ use chrono::TimeZone; use chrono::Utc; use super::{ - build_list_agent_runs_url, AgentMessageHeader, AgentRunEvent, AgentSource, - AmbientAgentTaskState, Artifact, ArtifactDownloadResponse, ArtifactType, ExecutionLocation, - ListRunsResponse, ReadAgentMessageResponse, RunSortBy, RunSortOrder, TaskListFilter, + build_list_agent_runs_url, build_run_followup_url, AgentMessageHeader, AgentRunEvent, + AgentSource, AmbientAgentTaskState, Artifact, ArtifactDownloadResponse, ArtifactType, + ExecutionLocation, ListRunsResponse, ReadAgentMessageResponse, RunFollowupRequest, RunSortBy, + RunSortOrder, TaskListFilter, }; use crate::notebooks::NotebookId; @@ -972,3 +973,28 @@ fn build_list_agent_runs_url_routes_to_runs_not_tasks() { assert!(url.starts_with("agent/runs?")); assert!(!url.starts_with("agent/tasks")); } + +#[test] +fn build_run_followup_url_routes_to_run_followups() { + let run_id = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(); + assert_eq!( + build_run_followup_url(&run_id), + "agent/runs/550e8400-e29b-41d4-a716-446655440000/followups" + ); +} + +#[test] +fn serialize_run_followup_request() { + let request = RunFollowupRequest { + message: "continue from here".to_string(), + }; + + let json = serde_json::to_value(request).unwrap(); + + assert_eq!( + json, + serde_json::json!({ + "message": "continue from here", + }) + ); +} diff --git a/app/src/terminal/shared_session/viewer/terminal_manager.rs b/app/src/terminal/shared_session/viewer/terminal_manager.rs index b3cc5bf63..54cf74389 100644 --- a/app/src/terminal/shared_session/viewer/terminal_manager.rs +++ b/app/src/terminal/shared_session/viewer/terminal_manager.rs @@ -767,13 +767,15 @@ impl TerminalManager { }; let is_ambient_agent = model.lock().is_shared_ambient_agent_session(); if is_ambient_agent { - Self::ambient_session_ended( + if !Self::end_current_ambient_session( &view, model.clone(), ¤t_network, &network, ctx, - ); + ) { + return; + } } else { Self::shared_session_ended(&view, model.clone(), ctx); } @@ -1537,13 +1539,19 @@ impl TerminalManager { .clear_write_to_pty_events_for_shared_session_tx(); } - fn ambient_session_ended( + fn end_current_ambient_session( terminal_view: &ViewHandle, model: Arc>, current_network: &Arc>>>, ended_network: &ModelHandle, ctx: &mut AppContext, - ) { + ) -> bool { + let ended_session_id = ended_network.as_ref(ctx).session_id(); + if !Self::current_network(current_network) + .is_some_and(|network| network.as_ref(ctx).session_id() == ended_session_id) + { + return false; + } Manager::handle(ctx).update(ctx, |manager, _| { manager.left_share(terminal_view.id()); }); @@ -1551,13 +1559,8 @@ impl TerminalManager { model .lock() .clear_write_to_pty_events_for_shared_session_tx(); - - let ended_session_id = ended_network.as_ref(ctx).session_id(); - if Self::current_network(current_network) - .is_some_and(|network| network.as_ref(ctx).session_id() == ended_session_id) - { - *current_network.lock() = None; - } + *current_network.lock() = None; + true } } diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 0a96e605e..b1f09220b 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -842,6 +842,8 @@ pub enum FeatureFlag { /// `base_model_context_window_limit` is not sent on outbound requests, so /// the server falls back to its default. ConfigurableContextWindow, + /// Enables continuing cloud mode conversations in the cloud after an execution ends. + HandoffCloudCloud, } static FLAG_STATES: [AtomicBool; cardinality::()] = diff --git a/specs/APP-4318/TECH.md b/specs/APP-4318/TECH.md new file mode 100644 index 000000000..6a7c9708d --- /dev/null +++ b/specs/APP-4318/TECH.md @@ -0,0 +1,157 @@ +# HandoffCloudCloud master tech spec +## Context +Cloud Mode ambient conversations currently treat one ambient task/run as one shared-session execution. The first Cloud Mode pane is created as a deferred shared-session viewer in `app/src/terminal/view/ambient_agent/mod.rs:35`; the ambient model emits `SessionReady`, and the viewer manager joins the session. This branch adds the foundation needed to reuse that same terminal view/model for later sessions: `TerminalManager::attach_followup_session` can replace the active viewer `Network` and join a fresh shared session in append mode in `app/src/terminal/shared_session/viewer/terminal_manager.rs:338`. +The hotswap foundation is documented separately in `specs/REMOTE-1478/TECH.md`. The important shipped boundary is that session IDs are transport-scoped, while the visible ambient conversation, terminal view, terminal model, and task/run identity remain stable across follow-up executions. The event loop already has a follow-up load mode, and `TerminalModel::append_followup_shared_session_scrollback` appends only unknown block IDs instead of replacing the blocklist in `app/src/terminal/shared_session/viewer/event_loop.rs:29`, `app/src/terminal/model/terminal_model.rs:1481`, and `app/src/terminal/model/blocks.rs:759`. +The remaining client work is broader than a small change. It touches the public API client, ambient task/run data models, ambient spawn/follow-up state transitions, the cloud conversation tombstone, terminal input behavior, and tests. This should be implemented as a stack of mergeable PRs rather than one large PR. +The server-side public API now exposes `POST /api/v1/agent/runs/{runId}/followups`, which accepts `RunFollowupRequest { message }` and returns an empty success object when the follow-up is accepted. The server contract says clients should observe readiness by fetching `GET /api/v1/agent/runs/{runId}` until the run exposes an active shared session. The route and handler are in `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:181` and `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:608`; the OpenAPI schema is in `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/public_api/openapi.yaml:644`. +Run executions are a new server abstraction. A stable run/task can have many execution attempts, each with its own input, state, shared session, conversation ID, and compute accounting. The server model is in `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/model/types/ai_run_executions.go:37`. The current client is not execution-aware: `AmbientAgentTask` stores one `session_id`, one `session_link`, one `conversation_id`, and one `is_sandbox_running` value in `app/src/ai/ambient_agents/task.rs:213`; `AIConversation` stores a single `task_id`/`run_id` in `app/src/ai/agent/conversation.rs:123`; `spawn_task` polls a task until it sees a single session ID in `app/src/ai/ambient_agents/spawn.rs:27`. +The Cloud Mode tombstone already exists and can render artifacts plus “Continue locally” from `ConversationEndedTombstoneView` in `app/src/terminal/view/shared_session/conversation_ended_tombstone_view.rs:139`. It is currently inserted by the generic shared-session end path in `TerminalView::on_session_share_ended` in `app/src/terminal/view/shared_session/view_impl.rs:685`. The new ambient hotswap path intentionally avoids that full teardown, so the follow-up implementation needs a dedicated ambient-execution-ended UI path that inserts the tombstone without making the viewer read-only or permanently finished. +The feature must be gated behind a new client feature flag, `HandoffCloudCloud`. Enabling `HandoffCloudCloud` must imply `CloudModeSetupV2` is enabled. Runtime code may assume `CloudModeSetupV2` behavior whenever `HandoffCloudCloud` is enabled, so new call sites should check only `FeatureFlag::HandoffCloudCloud` unless they are still preserving old setup-v1 behavior. +## Proposed changes +### Feature flag and rollout invariant +Add `FeatureFlag::HandoffCloudCloud` in `crates/warp_features/src/lib.rs` near `CloudModeSetupV2`. Do not add it to release/preview/dogfood lists until the full flow is ready for that audience. When it is added to any rollout list, add `CloudModeSetupV2` to the same list if it is not already there. +Add a small feature-flag test or helper assertion that encodes the dependency: any static rollout set containing `HandoffCloudCloud` must also contain `CloudModeSetupV2`. Runtime feature checks should assume that dependency rather than repeatedly checking both flags. +### Run/execution-aware client model +Introduce explicit run identity and execution-scoped types in the ambient agent model layer. A minimal shape is: +- `AmbientAgentRunId` or reuse `AmbientAgentTaskId` with clearer accessors while server/client naming finishes migrating from task to run. +- `AmbientAgentRunExecutionId` for server execution IDs when the public API starts returning them. +- `AmbientAgentRunExecutionSummary`, containing execution ID if present, state, shared session ID/link, conversation ID, started/updated timestamps, and whether the execution is active. +- `AmbientAgentRun`, or an evolved `AmbientAgentTask`, that exposes stable run fields separately from active/latest execution projections. +For the first client PR, the public API may still return flattened fields. The model should still expose execution-aware APIs that use those fields as the active/latest execution projection: +- `run_id()` +- `active_execution_session_id()` +- `latest_execution_session_id()` +- `active_execution_conversation_id()` +- `has_active_execution()` +- `is_terminal_run_state()` +- `can_submit_cloud_followup()` +Existing callers should stop reaching directly into `session_id`, `session_link`, `conversation_id`, and `is_sandbox_running` when the meaning is execution-scoped. That keeps later server responses with an `executions` array or `active_execution` object from forcing another cross-codebase rename. +`AIConversation` should keep stable conversation identity by server conversation token/conversation ID, while treating run/execution metadata as auxiliary. The current single `task_id` field can remain as the stable run ID for compatibility, but methods and comments should distinguish “run ID” from any future “execution ID”. Do not make a follow-up execution allocate a new local `AIConversationId`; following up continues the same local conversation and same server conversation/run. +`AgentConversationsModel` should merge fetched run data by stable run ID. Details panels and management lists can continue to show one row per run, with aggregate state from the run and active/latest execution session info for open/continue actions. +### Public API client +Add `RunFollowupRequest { message }` and `AIClient::submit_run_followup(run_id, message)` in `app/src/server/server_api/ai.rs`. Implement it as `POST agent/runs/{run_id}/followups` against the public API, returning `Result<(), anyhow::Error>`. +Split the current `spawn_task` stream into reusable pieces: +- `spawn_task(request, ai_client, timeout)` continues to create the initial run and monitor it. +- A new monitor helper polls an existing run until it reaches either a terminal error state or a new joinable execution session. +- A new follow-up helper calls `submit_run_followup`, then uses the monitor helper to wait for the next active execution session. +The follow-up monitor must know the previous execution session ID and ignore it. A ready follow-up session is a session ID that is present, parseable, active according to the run/execution projection, and different from the previous ended session ID. +### Ambient view model state +Extend `AmbientAgentViewModel` to track stable run state and per-execution startup state: +- stable run ID/task ID; +- local conversation ID; +- active execution session ID; +- last ended execution session ID; +- current startup kind: initial run or follow-up execution; +- the prompt currently being submitted for optimistic rendering. +Replace the implicit “if already `AgentRunning`, then a `SessionStarted` means follow-up” logic in `app/src/terminal/view/ambient_agent/model.rs:606` with explicit state. A good shape is `Status::WaitingForSession { progress, kind }`, where `kind` is `Initial` or `Followup`. +Add a public model method such as `submit_cloud_followup(prompt, ctx)`. It should: +1. require `HandoffCloudCloud`; +2. require an existing run ID; +3. record the follow-up prompt for optimistic rendering; +4. set `Status::WaitingForSession { kind: Followup }`; +5. emit a setup/loading event so the terminal shows the existing Cloud Mode setup UI; +6. call the follow-up API; +7. poll for the new session; +8. emit `FollowupSessionReady { session_id }` when ready; +9. surface errors through the same loading/error UI path as initial Cloud Mode setup. +When the follow-up session becomes ready, keep using `TerminalManager::attach_followup_session` rather than opening a new terminal view. When the follow-up execution starts, update the existing conversation status back to `ConversationStatus::InProgress`; when it finishes, update status based on the run state. +### Ambient execution-ended UI boundary +Add a dedicated ambient execution-ended path instead of using the generic shared-session teardown. The current branch’s `ambient_session_ended` in `app/src/terminal/shared_session/viewer/terminal_manager.rs:1516` should trigger a terminal-view method such as `on_ambient_agent_execution_ended`. +That terminal-view method should: +- insert the conversation-ended tombstone once per ended execution, or update the existing tombstone to represent the latest ended execution; +- keep the existing `TerminalView`, `TerminalModel`, ambient view model, and input model alive; +- keep the terminal pane eligible to attach another shared session; +- not set `SharedSessionStatus::FinishedViewer`; +- not put the editor into permanent read-only/selectable state; +- not cancel the local ambient conversation merely because the execution ended. +If multiple follow-ups are supported, the tombstone insertion policy should avoid stacking multiple large terminal-state cards in a way that buries the active input. The simplest acceptable policy for the first UI PR is to append a tombstone after each terminal execution, because each one marks a real boundary in the transcript. If that feels noisy in implementation review, the alternative is to keep one tombstone view and update it in place until the user submits the next follow-up. +### Tombstone Continue entrypoint and terminal input +Under `HandoffCloudCloud`, `ConversationEndedTombstoneView` should show a primary “Continue” action for ambient Cloud Mode tombstones that have a run ID and are eligible for cloud follow-up. Keep “Continue locally” as a secondary or fallback action. +Clicking “Continue” should reveal/focus the terminal input for a follow-up prompt. Prefer reusing the existing terminal input/editor rather than embedding a separate text editor inside the tombstone. That keeps submission, attachments, editor state, and keyboard behavior consistent with Cloud Mode setup. +The submit path should route through `AmbientAgentViewModel::submit_cloud_followup`, not through the shared-session viewer network. The previous shared session has ended, so there is no active sharer to receive `SendAgentPrompt`. The new run execution is created by the public follow-up API; once its shared session is ready, the viewer network is attached. +During setup, reuse the Cloud Mode setup-v2 loading screen and progress footer/screen instead of inventing a new progress UI. The initial-user-query rich content pattern in `CloudModeInitialUserQuery` can be generalized to render optimistic follow-up user prompts while the environment is starting. +### Conversation and blocklist continuity +Following up must preserve the same local `AIConversationId`, same server conversation ID, and same stable run ID. The new execution creates a new shared session and a new execution record, not a new user-visible conversation. +When the new session joins, append session scrollback using `SharedSessionInitialLoadMode::AppendFollowupScrollback`. The server/runtime contract should preserve `SerializedBlock.id` for prior rehydrated blocks or send continuation-only scrollback. Without one of those contracts, the client cannot reliably dedupe prior output from new output. +The existing hotswap append path should be treated as the only blocklist mutation path for follow-up execution output. Avoid separately loading conversation transcript data into the same view during follow-up setup, because that risks duplicating AI blocks and shell command blocks. +### Error handling +If `submit_run_followup` fails before the server accepts the prompt, restore the tombstone/input to an editable state and show the error in the same Cloud Mode setup area. Do not append the optimistic follow-up prompt permanently unless the server accepted it. +If the server accepts the follow-up but polling reaches a terminal failure before a new session is available, show the Cloud Mode error/cancelled/auth/capacity state and leave the tombstone/input available for retry when the server marks the run retryable. +If the user closes the pane while waiting for a follow-up session, cancel only local polling. Do not cancel the run unless the user explicitly invokes a cancel action. +## End-to-end flow +1. User starts a Cloud Mode ambient conversation. +2. The initial run is created with `POST /agent/run`; the client stores the stable run ID and local conversation ID. +3. `spawn_task` polls until the run exposes the initial active execution shared session. +4. `AmbientAgentViewModel` emits `SessionReady`; the viewer manager joins the initial session. +5. The execution reaches a terminal state and the shared session ends. +6. The viewer manager handles this as an ambient execution boundary, inserts or updates the tombstone, and keeps the pane/input resumable. +7. User clicks “Continue” on the tombstone and submits a prompt through the terminal input. +8. The ambient view model calls `POST /agent/runs/{runId}/followups`. +9. The client shows Cloud Mode setup-v2 loading UI while polling `GET /agent/runs/{runId}`. +10. When a new active execution session ID appears, `AmbientAgentViewModel` emits `FollowupSessionReady`. +11. The viewer manager calls `attach_followup_session`, replaces the network, and joins the new session in append mode. +12. New output streams into the same terminal view/model and same local conversation. +13. Steps 5-12 can repeat for additional follow-up executions. +## Increment plan +### PR 0: shared-session hotswap foundation +This is the current branch and can stand alone. It keeps reusable viewer resources across networks, adds `attach_followup_session`, adds append-mode scrollback loading, and prevents ambient `SessionEnded` from permanently poisoning the pane. The existing `specs/REMOTE-1478/TECH.md` covers this increment. +### PR 1: feature flag, API client, and run/execution-aware model scaffolding +Add `HandoffCloudCloud`, `submit_run_followup`, follow-up request/response tests, and run/execution-aware accessors on `AmbientAgentTask`/run models. Update existing call sites to use accessors for active session/conversation state. This PR should not expose UI or change runtime behavior except behind tests and the disabled flag. +Merge criteria: existing Cloud Mode spawn, task list, details panel, and shared-session viewer behavior are unchanged with the flag off. +### PR 2: ambient follow-up orchestration without the visible entrypoint +Add `AmbientAgentViewModel::submit_cloud_followup`, explicit initial-vs-follow-up waiting state, polling for a new active session, optimistic follow-up prompt state, and `FollowupSessionReady` wiring to the existing hotswap API. Add unit tests using a mocked `AIClient`. +This can be mergeable behind the disabled flag with a test-only or debug-only invocation path. No tombstone button is required yet. +Merge criteria: a model-level follow-up can accept a prompt, call the API, ignore the previous session ID, emit a new session ID, and handle API/polling errors. +### PR 3: tombstone Continue UX and terminal input submission +Add the cloud “Continue” tombstone action, reveal/focus the existing terminal input, route submission to the ambient follow-up model method, show setup-v2 loading UI, and render the optimistic follow-up prompt. Keep “Continue locally” available. Add telemetry for continue-in-cloud attempts, success, and failures. +Merge criteria: with `HandoffCloudCloud` off, the tombstone is unchanged; with it on, terminal-state Cloud Mode conversations can start a follow-up and attach the new shared session in the same pane. +### PR 4: polish, details panel, and end-to-end validation +Update conversation details/agent-management surfaces to use run/execution-aware helpers, ensure active/past sections treat a run with an active follow-up execution as active, and add integration coverage for at least two execution boundaries. This PR can also tune tombstone stacking/updating based on product review. +Merge criteria: repeated cloud follow-ups preserve one conversation/run identity, append output in order, and do not regress normal shared-session viewers or local “Continue locally”. +## Testing and validation +Unit tests: +- `AIClient::submit_run_followup` constructs `POST agent/runs/{runId}/followups` with `{ message }` and handles success/error responses. +- Run/execution accessors derive active/latest session state from current flattened API fields and from future optional execution-shaped test fixtures. +- Ambient follow-up model calls the API, transitions to `WaitingForSession { kind: Followup }`, polls until a new session ID appears, ignores the old session ID, emits `FollowupSessionReady`, and handles terminal failure states. +- `ConversationEndedTombstoneView` shows/hides Continue based on `HandoffCloudCloud`, task/run presence, AI settings, and target platform. +- Non-ambient shared-session `SessionEnded` still uses the generic finished/read-only viewer path. +Viewer/session tests: +- Follow-up attach replaces the active network and does not duplicate outbound subscriptions. +- Ambient `SessionEnded` inserts or updates a tombstone without setting `FinishedViewer`. +- Repeated follow-up sessions append scrollback without duplicating block IDs. +Integration or manual validation: +- Start a Cloud Mode conversation, wait for the execution to end, click Continue, submit a prompt, verify setup UI appears, then verify a fresh shared session attaches to the same pane. +- Repeat with a second follow-up to catch subscription leaks and stale session-ID handling. +- Verify “Continue locally” still forks locally from the same tombstone. +- Verify a normal shared-session viewer still becomes read-only and shows the ended banner when its session ends. +Before opening or updating PRs in this stack, run the repo-required formatting and clippy checks for touched Rust code, plus targeted tests for ambient model, server API client, tombstone view, and viewer terminal manager. For the user-facing UI increments, run UI verification with the `verify-ui-change-in-cloud` skill after implementation. +## Risks and mitigations +### Run/execution naming churn +Risk: continuing to use `task_id` everywhere makes execution-scoped changes confusing and easy to misuse. +Mitigation: add accessors and comments that separate stable run identity from execution-scoped session/conversation state, even if the underlying ID type remains `AmbientAgentTaskId` during migration. +### Stale session readiness +Risk: after submitting a follow-up, `GET /agent/runs/{runId}` may briefly return the ended execution’s previous session ID. +Mitigation: the follow-up monitor must track the previous session ID and require a different active execution session before emitting `FollowupSessionReady`. +### Duplicate transcript content +Risk: follow-up sessions may replay prior scrollback, and client-side transcript restoration may also try to render conversation data. +Mitigation: use only append-mode shared-session scrollback in the live pane, dedupe by block ID, and require the runtime/server to preserve block IDs or send continuation-only scrollback. +### Input routed to the wrong transport +Risk: the terminal input could accidentally send a prompt over the old shared-session `NetworkEvent::SendAgentPrompt` path. +Mitigation: while between executions, route submission through the ambient follow-up API path and keep `current_network` empty until a new session is attached. +### Feature flag dependency drift +Risk: `HandoffCloudCloud` could be enabled without setup-v2, exposing code paths that assume setup-v2 UI/state. +Mitigation: encode the rollout-list dependency in a test and document that runtime code only checks `HandoffCloudCloud`. +### Tombstone noise +Risk: repeated executions can append multiple large tombstones. +Mitigation: start with the simple append-per-execution behavior only if it reads well in product review; otherwise update the latest tombstone in place until the next follow-up is submitted. +## Parallelization +The work can split across three agents or branches after PR 1 lands: +- API/model track: follow-up client method, run/execution accessors, agent management/details model updates. +- Ambient orchestration track: view-model follow-up state machine, polling, and hotswap event wiring. +- UI track: tombstone Continue action, terminal input reveal/submission, optimistic prompt rendering, and setup-v2 loading/error states. +The tracks converge at `AmbientAgentViewModel::submit_cloud_followup` and the existing `FollowupSessionReady -> attach_followup_session` subscription in `create_cloud_mode_view`. +## Follow-ups +- Add first-class execution arrays or active/latest execution objects to the public API response once the server shape is ready, then remove flattened-field compatibility from client accessors. +- Decide whether conversation details should show per-execution runtime/credit rows or only aggregate run-level totals. +- Consider adding execution IDs to session-sharing source metadata so the client can associate a joined session with a specific execution without inferring from session ID. +- Remove the `HandoffCloudCloud` flag after cloud-to-cloud follow-ups are stable. diff --git a/specs/APP-4319/TECH.md b/specs/APP-4319/TECH.md new file mode 100644 index 000000000..62bf5eef0 --- /dev/null +++ b/specs/APP-4319/TECH.md @@ -0,0 +1,79 @@ +# Cloud-to-cloud handoff PR 1 tech spec +## Problem statement +The master cloud-to-cloud handoff plan needs an initial mergeable PR that adds the scaffolding required by later follow-up orchestration and UI work without changing end-user behavior. PR 1 should make the client aware of the `HandoffCloudCloud` rollout boundary, add a typed client method for the server follow-up API, and start isolating task/run identity from execution/session-scoped data in the client model. +This PR intentionally does not add the tombstone Continue entrypoint, does not submit follow-ups from the UI, and does not attach a follow-up shared session to an existing terminal. Those behaviors belong in later PRs after the foundational APIs exist. +## Current state +The current branch already adds the UI/model layer needed to attach a new backing shared session to an existing shared-session viewer; that foundation is documented in `specs/REMOTE-1478/TECH.md`. The broader sequencing and intended user flow are documented in `specs/handoff-cloud-cloud/TECH.md`. +Feature flags are defined in `crates/warp_features/src/lib.rs`. `CloudModeSetupV2` already exists near the end of the enum at `crates/warp_features/src/lib.rs:827`, and the rollout arrays are defined at `crates/warp_features/src/lib.rs:852`, `crates/warp_features/src/lib.rs:910`, and `crates/warp_features/src/lib.rs:926`. There is currently no `HandoffCloudCloud` flag. The Cargo feature graph in `app/Cargo.toml` is the right place to encode that `handoff_cloud_cloud` depends on `cloud_mode_setup_v2`. +The public API client already has ambient run methods for spawn, list, and get. The `AIClient` trait includes `spawn_agent`, `list_ambient_agent_tasks`, and `get_ambient_agent_task` at `app/src/server/server_api/ai.rs:799`, and the `ServerApi` implementation posts to `agent/run`, lists `agent/runs`, and gets `agent/runs/{task_id}` at `app/src/server/server_api/ai.rs:1404`. Adjacent public API methods post to run-scoped subresources such as `agent/runs/{task_id}/attachments/prepare` at `app/src/server/server_api/ai.rs:1785`, which is the natural implementation pattern for the follow-up API. +The server follow-up API is `POST /api/v1/agent/runs/{runId}/followups` with a JSON body containing `message`. The server route and request type are in the server worktree at `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/public_api/openapi.yaml:644`, `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:181`, `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/router/handlers/public_api/agent_webhooks.go:608`, and `/Users/zachbai/.warp-dev/worktrees/warp-server/cloud-agent-task-name/public_api/types/types.gen.go:1225`. +`AmbientAgentTask` currently exposes execution-scoped fields directly: `session_id`, `session_link`, `conversation_id`, `request_usage`, and `is_sandbox_running` at `app/src/ai/ambient_agents/task.rs:217`. `is_no_longer_running` already combines sandbox liveness with run state at `app/src/ai/ambient_agents/task.rs:257`. `SessionJoinInfo::from_task` reads `task.session_link` and `task.session_id` directly at `app/src/ai/ambient_agents/spawn.rs:32`. Agent management and details code also reads these flattened fields directly for session status, links, conversation dedupe, open actions, and details-panel display, including `app/src/ai/agent_conversations_model.rs:468`, `app/src/ai/agent_conversations_model.rs:515`, `app/src/ai/agent_conversations_model.rs:528`, `app/src/ai/agent_conversations_model.rs:553`, `app/src/ai/agent_conversations_model.rs:1293`, and `app/src/ai/conversation_details_panel.rs:337`. +## Goals +Add a disabled `HandoffCloudCloud` feature flag, with the app Cargo feature depending on `cloud_mode_setup_v2`. +Add typed client support for submitting a follow-up prompt to a run via the public API. +Introduce run/execution-aware accessors on `AmbientAgentTask` that preserve current behavior while giving later PRs a place to route active/latest-execution semantics. +Move the most important existing call sites from direct flattened fields to the new accessors when doing so is behavior-preserving and low-risk. +Add targeted unit tests for the new API serialization/path helper and task accessor behavior. +## Non-goals +No visible cloud conversation tombstone changes. +No terminal input submission changes. +No cloud mode setup UI changes. +No follow-up polling or shared-session hotswap orchestration. +No attempt to parse or store a full run-executions array unless the public API already returns it to the client in the current schema. PR 1 should define seams that can absorb that shape later. +No rollout enablement in `DOGFOOD_FLAGS`, `PREVIEW_FLAGS`, or `RELEASE_FLAGS`. +## Proposed changes +### Feature flag scaffolding +Add `HandoffCloudCloud` to `FeatureFlag` in `crates/warp_features/src/lib.rs`, preserving the enum's chronological ordering. Keep the description concise and product-focused, for example gating cloud-to-cloud continuation of cloud mode conversations. +Do not add the flag to any rollout arrays in PR 1. The flag should be available for local overrides and future PRs, but disabled by default. +Add `handoff_cloud_cloud = ["cloud_mode_setup_v2"]` to `app/Cargo.toml` and map the Cargo feature to `FeatureFlag::HandoffCloudCloud` in `app/src/lib.rs`. This keeps the dependency explicit in the build feature graph without adding rollout dependency tests. +### Follow-up API client +Add a request type near `SpawnAgentRequest` in `app/src/server/server_api/ai.rs`: +`RunFollowupRequest { message: String }`, serialized as `{"message":"..."}`. +Add an `AIClient` method named `submit_run_followup` with parameters `run_id: &AmbientAgentTaskId` and `request: RunFollowupRequest`, returning `anyhow::Result<(), anyhow::Error>`. The current server behavior does not expose a client-needed response payload, so implement with `post_public_api_unit(&build_run_followup_url(run_id), &request)`. +Add a small path helper, for example `build_run_followup_url(run_id: &AmbientAgentTaskId) -> String`, mirroring `build_list_agent_runs_url` at `app/src/server/server_api/ai.rs:521`. This makes unit testing the endpoint path independent from HTTP mocking. +Do not call this new method from UI or orchestration code in PR 1. +### Run/execution-aware task accessors +Add lightweight accessors to `AmbientAgentTask` in `app/src/ai/ambient_agents/task.rs`. For PR 1 these should project the existing flattened fields as the active/latest execution: +`run_id() -> AmbientAgentTaskId` returning `task_id`. +`conversation_id() -> Option<&str>` returning `conversation_id.as_deref()`. +`active_run_execution() -> RunExecution<'_>` returning a borrowed projection of the flattened execution-scoped fields. +Add `RunExecution<'a>` with borrowed `session_id`, non-empty `session_link`, `request_usage`, and `is_sandbox_running` fields. The key is that callers stop encoding the assumption that task-level fields are intrinsically task-scoped. +Update `is_no_longer_running` to use `active_run_execution().is_sandbox_running` and preserve existing behavior. +Move `SessionJoinInfo::from_task` in `app/src/ai/ambient_agents/spawn.rs:32` to use `active_run_execution()`. +Update low-risk UI/model call sites that only need the current projection: +`ConversationOrTask::session_id` should parse `active_run_execution().session_id`. +`link_preference` should use `active_run_execution().is_sandbox_running`. +`session_or_conversation_link` should use `active_run_execution().session_link` and `conversation_id()`. +`get_session_status` should use the session/link accessors. +`get_open_action` and conversation shadowing should use `run_id()` and `conversation_id()`. +`ConversationDetailsData::from_task` should use `conversation_id()` for the details-panel conversation id and `active_run_execution().request_usage` for credits. +Avoid broad mechanical churn in call sites where fields are clearly task metadata rather than execution data, such as title, prompt, state, source, creator, artifact list, and config snapshot. +### Optional naming cleanup +Do not rename `AmbientAgentTaskId` or public UI labels in PR 1. The server and much of the client still use run/task terminology interchangeably, and a broad rename would create churn without improving mergeability. The new `run_id()` accessor is enough to clarify stable identity for later PRs. +## Testing strategy +Add public API helper tests in `app/src/server/server_api/ai_test.rs` for the follow-up endpoint path. If the request type is public enough to serialize directly, add a serialization test asserting the JSON shape is exactly `{"message":"..."}`. +Add or update ambient task tests in `app/src/ai/ambient_agents/spawn_tests.rs` or a new `app/src/ai/ambient_agents/task_tests.rs` to cover: +`SessionJoinInfo::from_task` still prefers server-provided session links. +It still falls back to constructing a join link from `session_id`. +It returns `None` when neither an active link nor parseable session id exists. +The new accessors preserve current flattened-field behavior. +Run targeted checks after implementation: +`cargo nextest run -p warp server::server_api::ai::tests::build_run_followup_url_routes_to_run_followups server::server_api::ai::tests::serialize_run_followup_request ai::ambient_agents::spawn::tests` +`cargo nextest run -p warp ai::agent_conversations_model::tests ai::conversation_details_panel::tests` +`cargo check -p warp --features handoff_cloud_cloud` +The exact package names should be verified during implementation from `Cargo.toml` before running. Do not use `cargo fmt --all` or file-specific `cargo fmt`; if formatting is needed before review, use the repo’s standard `cargo fmt` per project guidance. +## Rollout and compatibility +The flag is disabled by default, so PR 1 should not change runtime behavior. +The new API client method is unused in PR 1 and should therefore be safe to merge before server rollout, as long as it compiles against existing client code. +The accessor migration should be behavior-preserving because each accessor initially projects the same flattened fields. If a direct field use is ambiguous or risky, leave it in place and document it as follow-up rather than expanding the PR scope. +## Risks and mitigations +The largest risk is accidentally changing management view link selection or session-open behavior while replacing direct field reads. Keep changes small, prefer local accessor substitutions, and rely on existing spawn and agent management tests where available. +The follow-up endpoint response shape may differ from the assumed empty response. During implementation, verify the server contract before choosing `post_public_api_unit`; if the server returns a body, add a minimal response type and test deserialization. +Runtime implication of `HandoffCloudCloud` to `CloudModeSetupV2` is intentionally not implemented in PR 1. The Cargo feature dependency covers compiled builds, while local runtime overrides can still force unusual states for targeted testing. +The accessor names may need to change once the client consumes first-class run-execution data. Keep PR 1 names descriptive but avoid adding a large abstraction that is not backed by current API data. +## Definition of done +`HandoffCloudCloud` exists and is disabled by default. +`handoff_cloud_cloud` in `app/Cargo.toml` depends on `cloud_mode_setup_v2`. +`AIClient` and `ServerApi` expose a typed follow-up submission method for `POST agent/runs/{run_id}/followups`. +`AmbientAgentTask` exposes run/execution-aware accessors and the main session/conversation call sites use them without behavior changes. +Targeted tests cover the follow-up API path/serialization and task/session accessor behavior, and a feature-gated compile check passes.