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-sideShouldSkipInitialTurnderivation +CloudModeSetupPhaseEndedprotocol-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.md—OrderedTerminalEventType::CloudModeSetupPhaseEndedvariant.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 /
&//handoffall dispatch the same immediate-handoff launch when the active source conversation has at least one exchange. - Wire-level substitution and telemetry —
build_handoff_spawn_requestsubstitutes"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
AgentDriverreads the--skip-initial-turnCLI flag rather than destructuring a stored client-side bool. - Setup-phase teardown marker —
AgentDriver::execute_runemitsCloudModeSetupPhaseEndedon 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— theHandoffChipClickedaction emits theAgentInputFooterEvent::HandoffChipClickedevent unconditionally (under theOzHandoff && HandoffLocalCloudflag gate plus thelocal_fs/non-wasm cfg). The terminalInputsubscriber atapp/src/terminal/input.rs:2547-2572then decides: dispatchWorkspaceAction::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 callactivate_cloud_handoff_composeso any in-flight prompt is preserved.app/src/terminal/input.rs:4037-4102—maybe_launch_cloud_handoff_requestdispatchesOpenLocalToCloudHandoffPane { 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—/handoffwith no argument dispatches the sameOpenLocalToCloudHandoffPane { 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_continuetrue + non-emptyInitialSnapshotToken: substituteprompt: Some("Continue. Apply the workspace changes from my previous session.")on the wire (the snapshot token still rides alongside). - Submitted prompt empty +
should_inject_continuetrue + no snapshot content: substituteprompt: Some("Continue"). - Submitted prompt empty +
should_inject_continuefalse + non-emptyInitialSnapshotToken: substituteprompt: Some("Apply the workspace changes from my previous session.")(the snapshot token still rides alongside). - Submitted prompt empty +
should_inject_continuefalse + no snapshot content: sendprompt: None. The worker derives--skip-initial-turnfrom the execution input. - Non-empty submitted prompt: pass it through unchanged.
The bool is captured once at handoff initiation as a local
source_conversation_activeinWorkspace::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 ontoPendingHandoff.should_inject_continuewhen the pane bootstraps (app/src/workspace/view.rs:14058; fresh-launch path stampsfalseat:13593). The samesource_conversation_activevalue drives the telemetryinjection_pathso the wire and analytics cannot drift across the in-progress cancellation that follows. Bothqueue_handoff_auto_submitandsubmit_handoffreadhandoff.should_inject_continueoff the pending handoff and pass it as an explicit parameter tobuild_handoff_spawn_requestrather than have the builder reach back intoself.pending_handoffmid-build.initial_snapshot_tokenisNonewhen called fromqueue_handoff_auto_submitandSome(_)when called fromsubmit_handoffafter the upload has settled. Callers MUST normalize an emptyStringprompt toNonebefore calling the builder; the builder treatsNoneas 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::HandoffInitiatedcarriesentry_point,forked_existing_conversation,empty_prompt: bool, andinjection_path: HandoffInjectionPath { None | Continue | SnapshotRehydration }.CloudAgentTelemetryEvent::HandoffSnapshotPrepared { derived_workspace_had_content: bool }is emitted by the model fromset_pending_handoff_workspaceafterderive_touched_workspacesettles. 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. Theinjection_pathvariant mirrors the wire substitution decision: non-empty prompt →None; empty + active source →Continue; empty + idle source →SnapshotRehydration. Computed at handoff initiation inWorkspace::start_local_to_cloud_handoff_from_source(app/src/workspace/view.rs:13778-13785) from the samesource_conversation_activebool 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.rs—SpawnAgentRequestcarries noskip_initial_turnfield. 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 askip_initial_turnvalue.app/src/ai/agent_sdk/driver.rs:387-398— theAgentRunPrompt::ServerSidevariant carries onlyskill: Option<ParsedSkill>andattachments_dir: Option<String>. The skip-initial-turn signal is intentionally not part of the prompt variant because the variant must round-trip throughprepare_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, 337—AgentDriverOptionsandAgentDrivereach carry askip_initial_turn: boolfield. The value is sourced fromRunAgentArgs::skip_initial_turn(which the clap parser populates from--skip-initial-turn) by thebuild_driver_options_and_taskclosure inmod.rs:875, then threaded intoAgentDriver::new. Thenew_for_testconstructor initializes the field tofalse.app/src/ai/agent_sdk/driver.rs:2382-2405— the gate inexecute_runnests the skip branch inside theServerSidematch arm:The outerif 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, ); } }matches!confines the skip-initial-turn short-circuit to the Oz harness path: third-party harnesses always resolve the server-side prompt throughprepare_harness, so they go down theAgentRunPrompt::Localarm at:2710-2750(whichprepare_harnessconstructs from the resolved prompt text) and never observeskip_initial_turn. The non-skipServerSidearm continues past this block into the history-model subscription and theif !self.skip_initial_turndispatch at:2710that firesAIAgentInput::StartFromAmbientRunPrompt.crates/warp_cli/src/agent.rs:368-380—--skip-initial-turnis a hidden boolean flag onoz agent runwithrequires_all = ["task_id", "idle_on_complete"]. Theidle_on_completerequirement pins the worker→driver invariant at the CLI layer: without an idle window the driver would exit immediately onSuccessbefore any follow-up could arrive. CLI parser tests atcrates/warp_cli/src/lib_tests.rs:232-296pin parsing and both rejection cases.
Considered alternatives
- Let the server decide unilaterally on the empty-prompt case — i.e. have the AgentDriver always dispatch
StartFromAmbientRunPromptand let the multi-agent runtime synthesize a terminal stream event (or send a no-opAddMessagesToTask) when it sees an empty resolved prompt, so the--skip-initial-turnCLI 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_idonStartFromAmbientRunPrompt; the resolved user-visible prompt is constructed byEffectivePromptForRunInputinside the multi-agent runtime in response to that dispatch, then streamed back to both the spawner and every session-sharing viewer asAddMessagesToTask(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_commandsflag needs an explicit "setup done" signal it can't get from the response stream. Pre-feature the flag was cleared as a side effect ofAppendedExchange, which materializes when the runtime's firstUserQueryreachesapply_client_actions. On the skip path there is no firstUserQuery, 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 emitsCloudModeSetupPhaseEndedon 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-turntells it. - Alternative driver-side framing: have the driver
awaitan 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 ofexecute_run, and--skip-initial-turnis the signal that picks the path.
- Prompt resolution is born inside the stream the dispatch triggers. The driver only ships
- Two separate empty-prompt predicates — one for the worker's
--skip-initial-turndecision intask_utils.go, one for the runtime's "run has no prompt" rejection inlined inbuildMessagesFromAmbientRunInput. This was the pre-refactor state. Rejected because the two predicates can silently diverge: the originalShouldSkipInitialTurnreturnedtrueon any empty effective prompt, while the runtime requiredAgentConversationID != ""for the same case, so the worker would happily skip a run the runtime would have rejected as invalid — a latent bug masked only byenqueueAgentRun'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 sharedcommon.ShouldSkipFirstTurn(task, userVisiblePrompt) (bool, error)predicate is the single source of truth; the worker'sShouldSkipInitialTurnwrapper resolves the prompt viaEffectivePromptForRunInputthen 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) onShouldSkipFirstTurn. 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:1419 — send_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-2754 — execute_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-212 — complete_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
AppendedExchangeas the teardown signal everywhere. Rejected: the skip-initial-turn path never firesAppendedExchange, so the viewer'sis_executing_oz_environment_startup_commandsflag staystrue,is_cloud_agent_pre_first_exchangekeeps 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
Successdirectly to the oneshot on the skip path (noIdleTimeoutSenderinvolvement). Rejected: the driver tears down ~80ms after sendingSuccess, which is too fast for a follow-up session-sharing-protocol exchange to arrive. Routing the skip path throughIdleTimeoutSender::complete_with_optional_idlehonorsidle_on_completeuniformly across completion paths.
Tests
- Behavioral tests in
app/src/terminal/view/ambient_agent/model_tests.rscover the three substitution outcomes inbuild_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).
- In-progress source + empty prompt → wire prompt is
- CLI parser tests at
crates/warp_cli/src/lib_tests.rs:232-296pin the--skip-initial-turnflag's parsing and bothrequires_allrejection 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.rscover theCloudModeSetupPhaseEndedarm 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_idletests inapp/src/ai/agent_sdk/driver_tests.rscoverNoneimmediate completion,Some(d)deferred completion, andSome(d)+ cross-pathcancel_idle_timeout()invalidation. - The driver-side
CloudModeSetupPhaseEndedemission 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 targetedcargo checkplus the per-stage unit tests cover the relevant surfaces.
Cross-repo coordination summary
SpawnAgentRequest(POST /agent/run): does not carryskip_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-levelAdditionalOzArgs []string(JSON tagadditional_oz_args,omitempty). warp-server computes the decision fresh per execution viaShouldSkipInitialTurn(simplified toprompt.is_empty()) and sends--skip-initial-turnin the slice only when applicable. The self-hosted worker forwards those tokens unchanged.--skip-initial-turnCLI 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 inCargo.toml:251-255keeps the locally-running relay decoding the new variant until the protocol PR merges and the relay picks up therevbump.