Files

26 KiB

Empty-Prompt Local-to-Cloud Handoff — Stage 2 Sub-Tech-Spec (warp)

Sub-tech-spec for what Stage 2 of REMOTE-1499 delivers on the warp side. The full end-to-end architecture lives in TECH.md; the full product behavior lives in PRODUCT.md. This document is scoped to the contents of harry/empty-prompt-handoff-local. Branch: harry/empty-prompt-handoff-local, stacked on Stage 1's harry/empty-prompt-handoff-wire-contract. Sibling specs (cross-repo):

  • warp-server/specs/REMOTE-1499/STAGE-2.md — server-side ShouldSkipInitialTurn derivation + CloudModeSetupPhaseEnded protocol-rev bump.
  • oz-agent-worker/specs/REMOTE-1499/STAGE-2.md — self-hosted worker side of the skip-initial-turn flag.
  • session-sharing-protocol/specs/REMOTE-1499/STAGE-2.mdOrderedTerminalEventType::CloudModeSetupPhaseEnded variant.
  • session-sharing-server/specs/REMOTE-1499/STAGE-2.md — testing-only protocol dep swap.

Scope

Stage 2 delivers the user-facing empty-prompt handoff behavior end-to-end on the warp side, shipped unconditionally (no feature flag). It is organized below by client surface:

  • Source-content guardrail and three entry points — chip / & / /handoff all dispatch the same immediate-handoff launch when the active source conversation has at least one exchange.
  • Wire-level substitution and telemetrybuild_handoff_spawn_request substitutes "Continue" against active sources, "Apply the workspace changes from my previous session." against idle sources with a non-empty snapshot token, and the concatenated "Continue. Apply the workspace changes from my previous session." when both are present; display is rendered verbatim from the wire prompt with no separate indicator enum. Telemetry tracks the intended substitution path.
  • Skip-initial-turn signal — the AgentDriver reads the --skip-initial-turn CLI flag rather than destructuring a stored client-side bool.
  • Setup-phase teardown markerAgentDriver::execute_run emits CloudModeSetupPhaseEnded on every cloud agent run; the viewer event loop tears down the Cloud Mode Setup V2 UI on receipt. Cross-repo coordination is summarized at the end of this doc.

Client entry points

All three entry points converge in Workspace::start_local_to_cloud_handoff_from_source (app/src/workspace/view.rs:13682), which synthesizes an empty PendingCloudLaunch for launch: None dispatches from HandoffEntryPoint::FooterChip | Ampersand | SlashCommand (see app/src/workspace/view.rs:13746-13767) and collects attachments from the source input so the three entry points stay symmetric. There is no separate handoff compose pane for any of the three entry points when the source has content — all three result in the same immediate-handoff dispatch.

  • app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs:2538-2548 — the HandoffChipClicked action emits the AgentInputFooterEvent::HandoffChipClicked event unconditionally (under the OzHandoff && HandoffLocalCloud flag gate plus the local_fs/non-wasm cfg). The terminal Input subscriber at app/src/terminal/input.rs:2547-2572 then decides: dispatch WorkspaceAction::OpenLocalToCloudHandoffPane { launch: None, environment_id: None, entry_point: HandoffEntryPoint::FooterChip } when the input buffer is empty AND the active source conversation is non-empty; otherwise call activate_cloud_handoff_compose so any in-flight prompt is preserved.
  • app/src/terminal/input.rs:4037-4102maybe_launch_cloud_handoff_request dispatches OpenLocalToCloudHandoffPane { launch: None, ... } on an empty buffer when the active source conversation is non-empty. Empty buffer without source content is a no-op so the compose draft is preserved. Entry point: HandoffEntryPoint::Ampersand.
  • app/src/terminal/input/slash_commands/mod.rs:900-950/handoff with no argument dispatches the same OpenLocalToCloudHandoffPane { launch: None, ... } as the chip when the active source conversation is non-empty. When it is empty, the command surfaces a "Nothing to hand off — start a conversation first." toast (no compose-mode fallback). Entry point: HandoffEntryPoint::SlashCommand.

Wire-level substitution (display = wire)

app/src/terminal/view/ambient_agent/model.rs:650-701 build_handoff_spawn_request(prompt, attachments, forked_conversation_id, initial_snapshot_token, should_inject_continue, ctx) -> SpawnAgentRequest:

  • Submitted prompt empty + should_inject_continue true + non-empty InitialSnapshotToken: substitute prompt: Some("Continue. Apply the workspace changes from my previous session.") on the wire (the snapshot token still rides alongside).
  • Submitted prompt empty + should_inject_continue true + no snapshot content: substitute prompt: Some("Continue").
  • Submitted prompt empty + should_inject_continue false + non-empty InitialSnapshotToken: substitute prompt: Some("Apply the workspace changes from my previous session.") (the snapshot token still rides alongside).
  • Submitted prompt empty + should_inject_continue false + no snapshot content: send prompt: None. The worker derives --skip-initial-turn from the execution input.
  • Non-empty submitted prompt: pass it through unchanged. The bool is captured once at handoff initiation as a local source_conversation_active in Workspace::start_local_to_cloud_handoff_from_source (app/src/workspace/view.rs:13775-13777) from the filtered (non-empty) source conversation's status (is_in_progress() || is_blocked()) and stamped onto PendingHandoff.should_inject_continue when the pane bootstraps (app/src/workspace/view.rs:14058; fresh-launch path stamps false at :13593). The same source_conversation_active value drives the telemetry injection_path so the wire and analytics cannot drift across the in-progress cancellation that follows. Both queue_handoff_auto_submit and submit_handoff read handoff.should_inject_continue off the pending handoff and pass it as an explicit parameter to build_handoff_spawn_request rather than have the builder reach back into self.pending_handoff mid-build. initial_snapshot_token is None when called from queue_handoff_auto_submit and Some(_) when called from submit_handoff after the upload has settled. Callers MUST normalize an empty String prompt to None before calling the builder; the builder treats None as the empty-prompt signal.

Queued-prompt indicator (no enum)

app/src/terminal/view/ambient_agent/view_impl.rs:153-170 (in AmbientAgentViewModelEvent::DispatchedAgent) inserts the queued-prompt block using display_user_query_with_mode(request.mode, prompt) against request.prompt.as_deref(). The whole block is gated by FeatureFlag::CloudModeSetupV2.is_enabled(), and the if !prompt.is_empty() guard at :168 suppresses insertion when the wire prompt is None (the unwrap_or_default() on the Option<String> collapses the missing-prompt case to an empty string). There is no EmptyPromptHandoffIndicator enum, no decoupled label rendering, and no submitted_with_empty_prompt bookkeeping on PendingHandoff — display tracks the wire one-to-one.

Telemetry

app/src/ai/ambient_agents/telemetry.rs:

  • CloudAgentTelemetryEvent::HandoffInitiated carries entry_point, forked_existing_conversation, empty_prompt: bool, and injection_path: HandoffInjectionPath { None | Continue | SnapshotRehydration }.
  • CloudAgentTelemetryEvent::HandoffSnapshotPrepared { derived_workspace_had_content: bool } is emitted by the model from set_pending_handoff_workspace after derive_touched_workspace settles. The field name makes clear the event reports what the snapshot pipeline produced — the upload itself may still fail downstream, so a true value does not imply the wire prompt fired the rehydration substitution. The injection_path variant mirrors the wire substitution decision: non-empty prompt → None; empty + active source → Continue; empty + idle source → SnapshotRehydration. Computed at handoff initiation in Workspace::start_local_to_cloud_handoff_from_source (app/src/workspace/view.rs:13778-13785) from the same source_conversation_active bool that drives the wire substitution.

Skip-initial-turn signal

The decision "should the cloud agent skip its initial LLM turn?" is computed fresh per execution on the server (common.ShouldSkipInitialTurn(task, execution) in warp-server) and reaches the sandboxed CLI as the --skip-initial-turn flag. With the client always substituting a non-empty prompt when a snapshot is present, the server-side helper simplifies to execution.Input.Prompt.is_empty(). The flag is the entire worker→driver contract; the wire shape between client and server is silent on it.

  • app/src/server/server_api/ai.rsSpawnAgentRequest carries no skip_initial_turn field. The client never derives or transmits this signal.
  • app/src/terminal/view/ambient_agent/model.rs:650-701 (build_handoff_spawn_request) and :1175-1203 (spawn_agent) decide only the wire-level prompt; neither emits a skip_initial_turn value.
  • app/src/ai/agent_sdk/driver.rs:387-398 — the AgentRunPrompt::ServerSide variant carries only skill: Option<ParsedSkill> and attachments_dir: Option<String>. The skip-initial-turn signal is intentionally not part of the prompt variant because the variant must round-trip through prepare_harness (which is harness-agnostic) without ferrying a flag that's meaningful only on the Oz harness.
  • app/src/ai/agent_sdk/driver.rs:261, 337AgentDriverOptions and AgentDriver each carry a skip_initial_turn: bool field. The value is sourced from RunAgentArgs::skip_initial_turn (which the clap parser populates from --skip-initial-turn) by the build_driver_options_and_task closure in mod.rs:875, then threaded into AgentDriver::new. The new_for_test constructor initializes the field to false.
  • app/src/ai/agent_sdk/driver.rs:2382-2405 — the gate in execute_run nests the skip branch inside the ServerSide match arm:
    if matches!(&task_prompt, AgentRunPrompt::ServerSide { .. }) {
        self.terminal_driver.update(ctx, |td, ctx| {
            td.with_terminal_view(ctx, |terminal, ctx| {
                if FeatureFlag::AgentView.is_enabled() {
                    terminal.enter_agent_view(
                        None,
                        restored_conversation_id,
                        AgentViewEntryOrigin::Cli,
                        ctx,
                    );
                }
                terminal
                    .model
                    .lock()
                    .send_cloud_mode_setup_phase_ended_for_shared_session();
            })
        });
        if self.skip_initial_turn {
            run_exit.complete_with_optional_idle(
                self.idle_on_complete,
                SDKConversationOutputStatus::Success,
            );
        }
    }
    
    The outer matches! confines the skip-initial-turn short-circuit to the Oz harness path: third-party harnesses always resolve the server-side prompt through prepare_harness, so they go down the AgentRunPrompt::Local arm at :2710-2750 (which prepare_harness constructs from the resolved prompt text) and never observe skip_initial_turn. The non-skip ServerSide arm continues past this block into the history-model subscription and the if !self.skip_initial_turn dispatch at :2710 that fires AIAgentInput::StartFromAmbientRunPrompt.
  • crates/warp_cli/src/agent.rs:368-380--skip-initial-turn is a hidden boolean flag on oz agent run with requires_all = ["task_id", "idle_on_complete"]. The idle_on_complete requirement pins the worker→driver invariant at the CLI layer: without an idle window the driver would exit immediately on Success before any follow-up could arrive. CLI parser tests at crates/warp_cli/src/lib_tests.rs:232-296 pin parsing and both rejection cases.

Considered alternatives

  • Let the server decide unilaterally on the empty-prompt case — i.e. have the AgentDriver always dispatch StartFromAmbientRunPrompt and let the multi-agent runtime synthesize a terminal stream event (or send a no-op AddMessagesToTask) when it sees an empty resolved prompt, so the --skip-initial-turn CLI flag wouldn't exist at all. Rejected because the driver still has to make the skip vs. dispatch fork before it knows what the runtime will do, for two reasons rooted in the session-sharing flow:
    • Prompt resolution is born inside the stream the dispatch triggers. The driver only ships ambient_run_id on StartFromAmbientRunPrompt; the resolved user-visible prompt is constructed by EffectivePromptForRunInput inside the multi-agent runtime in response to that dispatch, then streamed back to both the spawner and every session-sharing viewer as AddMessagesToTask(UserQuery) actions. There is no separate channel that carries the prompt text to the viewer's transcript. So "have the server decide" really means "dispatch normally, then have the server stream back an empty UserQuery that no one renders." That still warms up the runtime, opens the response stream, charges the apply-client-actions path, and immediately tears it back down — strictly more work than not dispatching in the first place.
    • The viewer's is_executing_oz_environment_startup_commands flag needs an explicit "setup done" signal it can't get from the response stream. Pre-feature the flag was cleared as a side effect of AppendedExchange, which materializes when the runtime's first UserQuery reaches apply_client_actions. On the skip path there is no first UserQuery, so even if the server streamed back a no-op the viewer's input box would never re-appear. That's exactly why the driver also emits CloudModeSetupPhaseEnded on the skip branch (§4). Coupling the two signals — "don't dispatch" + "explicitly mark setup done" — requires the driver to know in advance that no first turn is coming, which is precisely what --skip-initial-turn tells it.
    • Alternative driver-side framing: have the driver await an input signal and dispatch only when it knows there's content. Same problem in reverse: the input signal lives inside the response stream the dispatch would have triggered, so a driver that waits-then-dispatches deadlocks against itself. The driver has to commit to one of two control-flow paths at the top of execute_run, and --skip-initial-turn is the signal that picks the path.
  • Two separate empty-prompt predicates — one for the worker's --skip-initial-turn decision in task_utils.go, one for the runtime's "run has no prompt" rejection inlined in buildMessagesFromAmbientRunInput. This was the pre-refactor state. Rejected because the two predicates can silently diverge: the original ShouldSkipInitialTurn returned true on any empty effective prompt, while the runtime required AgentConversationID != "" for the same case, so the worker would happily skip a run the runtime would have rejected as invalid — a latent bug masked only by enqueueAgentRun's upstream rejection. Adding a new server-known content source (e.g. a skill-derived preamble) would have required editing both sites in lockstep. The shared common.ShouldSkipFirstTurn(task, userVisiblePrompt) (bool, error) predicate is the single source of truth; the worker's ShouldSkipInitialTurn wrapper resolves the prompt via EffectivePromptForRunInput then delegates, and the runtime calls the same predicate against the already-resolved user-visible prompt.
  • A three-variant enum return (e.g. AllowedEmpty | HasContent | ErrorEmpty) on ShouldSkipFirstTurn. Considered for explicitness — the predicate has three meaningful outcomes — but rejected in favor of Go's (value, error) convention: (true, nil) is allowed-empty (skip), (false, nil) is has-content (don't skip), (false, err) carries the runtime's "run has no prompt" error directly so the runtime can return it without re-wrapping. The enum framing introduced switch-on-variant ceremony at every call site without expressing anything the (bool, error) shape doesn't already convey.

Setup-phase teardown marker

Every cloud agent run signals "environment setup phase complete" via the OrderedTerminalEventType::CloudModeSetupPhaseEnded shared-session-protocol marker. The sharer emits the marker once setup commands have finished; the viewer's event loop receives it and tears down the Cloud Mode Setup V2 UI. The marker is path-agnostic — it fires on both the skip-initial-turn path (no first LLM turn) and the normal ServerSide path (a first LLM turn follows). This makes the setup-phase teardown independent of whether a first AppendedExchange event will ever fire.

Why a dedicated marker is needed

The viewer's cloud-mode TerminalModel is constructed with BlockList::is_executing_oz_environment_startup_commands = true (set in cloud-mode terminal construction; see the explanatory comment at app/src/pane_group/mod.rs:5843-5851 where the restored-pane path clears it before replay). That flag gates the pre-first-exchange UI state: is_cloud_agent_pre_first_exchange (app/src/terminal/view/ambient_agent/mod.rs:144-196) reads it to hide the input box and render the loading footer in its place, and maybe_insert_setup_command_blocks (app/src/terminal/view/ambient_agent/view_impl.rs:420-518) reads it to wrap incoming blocks under the "Running setup commands…" chip. Pre-feature the flag was implicitly cleared by the AppendedExchange handler in app/src/terminal/view.rs — i.e. the act of the first LLM turn starting was treated as proof setup must be done. The skip-initial-turn path never fires AppendedExchange, so without an explicit signal the flag stays true forever, the input stays hidden behind the loading footer, and the user cannot type a follow-up at all. CloudModeSetupPhaseEnded is the explicit "setup is done" signal that the viewer needs to flip the flag and reveal the input.

Testing-only Cargo.toml swap

Cargo.toml:251-255 — the session-sharing-protocol dep is set to path = "../session-sharing-protocol" while the protocol PR is in flight (the git = ..., rev = ... form is kept commented just above so the swap-back is mechanical). Reverted to git = ..., rev = <merged SHA> after the protocol PR merges; the locally-running session-sharing-server must pick up the same rev bump before warp lands, or the relay will type-decode OrderedTerminalEventType against an older protocol rev that lacks the new variant and silently drop the marker. Tracked in PRODUCT.md "Deferred follow-ups".

TerminalModel helper

app/src/terminal/model/terminal_model.rs:1419send_cloud_mode_setup_phase_ended_for_shared_session is modeled on the adjacent send_agent_conversation_replay_started_for_shared_session. The helper is a no-op for non-sharer terminals; sharer terminals emit a typed OrderedTerminalEventType::CloudModeSetupPhaseEnded event through the existing shared-session event channel.

AgentDriver::execute_run structure

app/src/ai/agent_sdk/driver.rs:2363-2754execute_run builds IdleTimeoutSender first, then runs a single unified ServerSide-only block (:2382-2405) that enters the agent view, emits the CloudModeSetupPhaseEnded marker via send_cloud_mode_setup_phase_ended_for_shared_session, and — only when skip_initial_turn is set — calls complete_with_optional_idle to schedule the deferred Success. Scheduling the timer before the history subscription set up at :2407-2657 means a later AppendedExchange from a session-sharing-protocol follow-up correctly invalidates the timer via IdleTimeoutSender's internal generation counter (run_exit.cancel_idle_timeout() in the AppendedExchange handler at :2494). After the subscription is installed, :2710-2750 dispatches the initial StartFromAmbientRunPrompt only when !self.skip_initial_turn, so the skip path falls through to rx without ever firing a first turn.

Marker emission is unified across skip and non-skip

app/src/ai/agent_sdk/driver.rs:2382-2405 is the only call site for send_cloud_mode_setup_phase_ended_for_shared_session() in the driver. The single emission inside the if matches!(&task_prompt, AgentRunPrompt::ServerSide { .. }) block fires before the if self.skip_initial_turn branch, so the skip and non-skip ServerSide paths share one emission point. The AgentRunPrompt::Local arm does not call the helper — local runs do not have a setup phase, and even though the helper is internally guarded by is_sharer(), keeping the call site narrow makes the emission boundary obvious.

IdleTimeoutSender::complete_with_optional_idle

app/src/ai/agent_sdk/driver.rs:206-212complete_with_optional_idle(idle_on_complete, value) defers via end_run_after when idle_on_complete is Some(d) and falls back to end_run_now when None. The UpdatedConversationStatus and harness-exit branches in execute_run use the same helper so all completion paths honor the optional idle window uniformly.

Viewer event_loop.rs arm

app/src/terminal/shared_session/viewer/event_loop.rs:348-357 — the OrderedTerminalEventType::CloudModeSetupPhaseEnded arm upgrades the terminal view and calls view.tear_down_cloud_mode_setup_phase(ctx). That helper lives at app/src/terminal/view.rs:7158-7168 and owns both pieces of state: it flips BlockList::set_is_executing_oz_environment_startup_commands(false) and, if an ambient_agent_view_model exists, calls AmbientAgentViewModel::tear_down_active_setup_command_group (app/src/terminal/view/ambient_agent/model.rs:382-386), which runs finish_setup_command_group + set_setup_command_group_visibility(false). Both inner calls no-op when there is no active or expanded group, so the wrapper is idempotent. The arm is path-agnostic — it handles both the skip-initial-turn path and the normal cloud agent path.

Legacy fallback teardowns

Two BlocklistAIHistoryEvent::AppendedExchange-driven teardowns at app/src/terminal/view.rs:5472-5483 (which clears is_executing_oz_environment_startup_commands only) and app/src/terminal/view/ambient_agent/block/setup_command_text.rs:118-130 (which calls finish_setup_command_group + set_setup_command_group_visibility(false) then unsubscribes) remain in place as a compatibility fallback for viewers that connect to sharers running pre-feature builds. Together they cover the same state the new tear_down_cloud_mode_setup_phase wrapper does. Both teardowns are idempotent with the CloudModeSetupPhaseEnded arm, so a new sharer + new viewer pair triggering both is harmless. Removal is tracked in PRODUCT.md "Deferred follow-ups".

Considered alternatives

  • Reusing AppendedExchange as the teardown signal everywhere. Rejected: the skip-initial-turn path never fires AppendedExchange, so the viewer's is_executing_oz_environment_startup_commands flag stays true, is_cloud_agent_pre_first_exchange keeps reporting the pane is in setup, the input box stays hidden behind the loading footer, and the user cannot type a follow-up at all. A dedicated marker decouples setup-phase teardown from first-LLM-turn semantics.
  • Letting the AgentDriver send Success directly to the oneshot on the skip path (no IdleTimeoutSender involvement). Rejected: the driver tears down ~80ms after sending Success, which is too fast for a follow-up session-sharing-protocol exchange to arrive. Routing the skip path through IdleTimeoutSender::complete_with_optional_idle honors idle_on_complete uniformly across completion paths.

Tests

  • Behavioral tests in app/src/terminal/view/ambient_agent/model_tests.rs cover the three substitution outcomes in build_handoff_spawn_request:
    • In-progress source + empty prompt → wire prompt is "Continue" (queue path).
    • Idle source + empty prompt → wire prompt is None (queue path; the snapshot token is not known at this stage).
    • Idle source + empty prompt + uploaded snapshot token → wire prompt is "Apply the workspace changes from my previous session." (submit_handoff path; the snapshot token rides alongside).
    • Idle source + empty prompt + skipped snapshot → wire prompt is None (submit_handoff path).
  • CLI parser tests at crates/warp_cli/src/lib_tests.rs:232-296 pin the --skip-initial-turn flag's parsing and both requires_all rejection cases (agent_run_accepts_skip_initial_turn_with_task_id_and_idle_on_complete, agent_run_rejects_skip_initial_turn_without_idle_on_complete, agent_run_rejects_skip_initial_turn_without_task_id).
  • Viewer-side tests in app/src/terminal/shared_session/viewer/event_loop_tests.rs cover the CloudModeSetupPhaseEnded arm and its idempotency.
  • Sandbox-side unit tests cover TerminalModel::send_cloud_mode_setup_phase_ended_for_shared_session (sharer-emits + non-sharer-no-op).
  • Direct IdleTimeoutSender::complete_with_optional_idle tests in app/src/ai/agent_sdk/driver_tests.rs cover None immediate completion, Some(d) deferred completion, and Some(d) + cross-path cancel_idle_timeout() invalidation.
  • The driver-side CloudModeSetupPhaseEnded emission on the non-skip path is exercised end-to-end by the standard cloud-mode handoff smoke test.

Validation

  • cargo fmt --all --check.
  • cargo check -p warp --tests. Nextest and full clippy are intentionally not part of the per-PR validation for this work — the changes touch isolated client-side wiring, and the targeted cargo check plus the per-stage unit tests cover the relevant surfaces.

Cross-repo coordination summary

  • SpawnAgentRequest (POST /agent/run): does not carry skip_initial_turn. The wire-shape change is local to this stage and is the only Stage-2 contract between warp and warp-server.
  • TaskAssignmentMessage (warp-server → self-hosted worker): top-level AdditionalOzArgs []string (JSON tag additional_oz_args, omitempty). warp-server computes the decision fresh per execution via ShouldSkipInitialTurn (simplified to prompt.is_empty()) and sends --skip-initial-turn in the slice only when applicable. The self-hosted worker forwards those tokens unchanged.
  • --skip-initial-turn CLI flag (worker → CLI): the sole worker→driver contract for the skip-initial-turn decision.
  • OrderedTerminalEventType::CloudModeSetupPhaseEnded (sharer → viewer via session-sharing-protocol): new variant in the protocol crate. The testing-only protocol dep swap in Cargo.toml:251-255 keeps the locally-running relay decoding the new variant until the protocol PR merges and the relay picks up the rev bump.